Compare commits
88 Commits
v1.12.0-rc
...
fix/migrat
| Author | SHA1 | Date | |
|---|---|---|---|
| e7e2aa9bd8 | |||
| 231ef9c27e | |||
| 6f35342a83 | |||
| a51110d276 | |||
| 7f81231467 | |||
| 439262fd02 | |||
| 93a184355b | |||
| 1dea0b8fab | |||
| ea7a2c2712 | |||
| deb3a63fb8 | |||
| cc05af2062 | |||
| 9026aabe3b | |||
| b844e166a9 | |||
| 950951de75 | |||
| c37e10faab | |||
| fdf6efe94e | |||
| 4c1eb8f874 | |||
| e547b0b410 | |||
| 803edf5b16 | |||
| 86c133ae84 | |||
| c28c5ab91d | |||
| d1eb14ac16 | |||
| f24b71f559 | |||
| 2ee0d77870 | |||
| 9b01a2318f | |||
| 5689cd1538 | |||
| 9d5b573dda | |||
| c48486472a | |||
| 1e2388519c | |||
| 20198b5b6c | |||
| 7cbf527eb3 | |||
| 767b66672e | |||
| 109a49826c | |||
| 3409aae411 | |||
| 07119f0e8d | |||
| 7a5a9eefe8 | |||
| 5570690b3b | |||
| 9ea56a77ff | |||
| 32c94118ce | |||
| 512e3555b4 | |||
| c47dc8749a | |||
| 32a5d33a16 | |||
| e5aaa17545 | |||
| f9d7fd7d9a | |||
| 5083ecb4b8 | |||
| 168648164b | |||
| 202e9fedb9 | |||
| 939bbcdb33 | |||
| 70f6036525 | |||
| 122e25b491 | |||
| ca9a70ced5 | |||
| 55abecc526 | |||
| 49c70fc8a8 | |||
| 4195a871ce | |||
| 37ed5ad222 | |||
| d6c11bd195 | |||
| cb73d21e05 | |||
| 106f796fea | |||
| 9917def0ca | |||
| cdb9b9ee03 | |||
| 8d1d098e3a | |||
| b682d2785f | |||
| 1a1a30791e | |||
| ea1cf481eb | |||
| eda0d5eeb6 | |||
| 8da4ab533f | |||
| 8695ef766e | |||
| 7487399123 | |||
| 0cc729e9bd | |||
| 58d97518c8 | |||
| 20c8969272 | |||
| 85ac65e405 | |||
| e07a497b69 | |||
| 21dc4eee62 | |||
| dc2042a1ee | |||
| bb9ba80edb | |||
| bfe8c674f2 | |||
| ebe1baf0a0 | |||
| 2345de679b | |||
| 1be0e2842c | |||
| 29a03d4ec7 | |||
| 039cd7d449 | |||
| 484f6c8b85 | |||
| 4fd8a767b2 | |||
| b8e08e88ac | |||
| 031a7b9e36 | |||
| 12fe045195 | |||
| 614106a5e4 |
12
.env.example
@ -105,6 +105,12 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
||||
# OPTIONAL: Displays the maximum document upload limit to the user in MBs
|
||||
NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
|
||||
|
||||
# [[EE ONLY]]
|
||||
# OPTIONAL: The AWS SES API KEY to verify email domains with.
|
||||
NEXT_PRIVATE_SES_ACCESS_KEY_ID=
|
||||
NEXT_PRIVATE_SES_SECRET_ACCESS_KEY=
|
||||
NEXT_PRIVATE_SES_REGION=
|
||||
|
||||
# [[STRIPE]]
|
||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||
@ -127,4 +133,8 @@ E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
|
||||
# [[LOGGER]]
|
||||
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=
|
||||
# OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
|
||||
NEXT_PRIVATE_LOGGER_FILE_PATH=
|
||||
|
||||
# [[PLAIN SUPPORT]]
|
||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||
|
||||
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -1,8 +1,3 @@
|
||||
---
|
||||
name: Pull Request
|
||||
about: Submit changes to the project for review and inclusion
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!--- Describe the changes introduced by this pull request. -->
|
||||
|
||||
2
.github/workflows/translations-upload.yml
vendored
@ -20,8 +20,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
|
||||
- uses: ./.github/actions/node-install
|
||||
|
||||
|
||||
7
.gitignore
vendored
@ -50,3 +50,10 @@ yarn-error.log*
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# logs
|
||||
logs.json
|
||||
|
||||
# claude
|
||||
.claude
|
||||
CLAUDE.md
|
||||
@ -49,8 +49,6 @@ Join us in creating the next generation of open trust infrastructure.
|
||||
|
||||
## Community and Next Steps 🎯
|
||||
|
||||
We're currently working on a redesign of the application, including a revamp of the codebase, so Documenso can be more intuitive to use and robust to develop upon.
|
||||
|
||||
- Check out the first source code release in this repository and test it.
|
||||
- Tell us what you think in the [Discussions](https://github.com/documenso/documenso/discussions).
|
||||
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"index": "Get Started",
|
||||
"authentication": "Authentication",
|
||||
"rate-limits": "Rate Limits",
|
||||
"versioning": "Versioning"
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ Our new API V2 supports the following typed SDKs:
|
||||
|
||||
<Callout type="info">
|
||||
For the staging API, please use the following base URL:
|
||||
`https://stg-app.documenso.dev/api/v2-beta/`
|
||||
`https://stg-app.documenso.com/api/v2-beta/`
|
||||
</Callout>
|
||||
|
||||
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
import { Callout } from 'nextra/components';
|
||||
|
||||
# Rate Limits
|
||||
|
||||
Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
|
||||
## HTTP Rate Limits
|
||||
|
||||
**Limit:** 100 requests per minute per IP address
|
||||
**Response:** 429 Too Many Requests
|
||||
|
||||
### Rate Limit Response
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Too many requests, please try again later."
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="warning">
|
||||
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
|
||||
seconds before retrying.
|
||||
</Callout>
|
||||
|
||||
## Resource Limits
|
||||
|
||||
Beyond HTTP rate limits, your account has usage limits based on your subscription plan.
|
||||
|
||||
### Plan Limits
|
||||
|
||||
| Resource | Free | Paid | Self-hosted | Enterprise |
|
||||
| ---------------- | ---- | --------- | ----------- | ---------- |
|
||||
| Documents/month | 5 | Unlimited | Unlimited | Unlimited |
|
||||
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
|
||||
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
|
||||
|
||||
### Error Response
|
||||
|
||||
When you exceed a resource limit:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "You have reached your document limit for this month. Please upgrade your plan.",
|
||||
"code": "LIMIT_EXCEEDED",
|
||||
"statusCode": 400
|
||||
}
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Status | Description |
|
||||
| ------------------- | ------ | ----------------------------- |
|
||||
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
|
||||
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
|
||||
@ -619,6 +619,18 @@ Example payload for the `document.rejected` event:
|
||||
}
|
||||
```
|
||||
|
||||
## Webhook Events Testing
|
||||
|
||||
You can trigger test webhook events to test the webhook functionality. To trigger a test webhook, navigate to the [Webhooks page](/developers/webhooks) and click on the "Test Webhook" button.
|
||||
|
||||

|
||||
|
||||
This opens a dialog where you can select the event type to test.
|
||||
|
||||

|
||||
|
||||
Choose the appropriate event and click "Send Test Webhook." You’ll shortly receive a test payload from Documenso with sample data.
|
||||
|
||||
## Availability
|
||||
|
||||
Webhooks are available to individual users and teams.
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
"documents": "Documents",
|
||||
"templates": "Templates",
|
||||
"branding": "Branding",
|
||||
"email-domains": "Email Domains",
|
||||
"direct-links": "Direct Signing Links",
|
||||
"-- Legal Overview": {
|
||||
"type": "separator",
|
||||
|
||||
@ -17,7 +17,7 @@ Branding preferences can be set on either the organisation or team level.
|
||||
|
||||
By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time.
|
||||
|
||||
To access the preferences, navigate to either the organisation or teams settings page and click the **Preferences** tab. This page contains both the preferences for documents and branding, the branding section is located at the bottom of the page.
|
||||
To access the preferences, navigate to either the organisation or teams settings page and click the **Branding** tab under the **Preferences** section.
|
||||
|
||||

|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
{
|
||||
"sending-documents": "Sending Documents",
|
||||
"document-preferences": "Document Preferences",
|
||||
"document-visibility": "Document Visibility"
|
||||
"document-visibility": "Document Visibility",
|
||||
"fields": "Document Fields",
|
||||
"email-preferences": "Email Preferences"
|
||||
}
|
||||
@ -19,12 +19,14 @@ Document preferences can be set on either the organisation or team level.
|
||||
|
||||
By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time.
|
||||
|
||||
To access the preferences, navigate to either the organisation or teams settings page and click the **Preferences** tab.
|
||||
To access the preferences, navigate to either the organisation or teams settings page and click the **Document** tab under the **Preferences** section.
|
||||
|
||||

|
||||
|
||||
- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/documents/document-visibility).
|
||||
- **Default Document Language** - This setting allows you to set the default language for the documents uploaded in the organisation. The default language is used as the default language in the email communications with the document recipients.
|
||||
- **Default Time Zone** - The timezone to use for date fields and signing the document.
|
||||
- **Default Date Format** - The date format to use for date fields and signing the document.
|
||||
- **Signature Settings** - Controls what signatures are allowed to be used when signing the documents.
|
||||
- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. See more below [sender details](/users/documents/document-preferences#sender-details).
|
||||
- **Include the Signing Certificate** - This setting controls whether the signing certificate should be included in the signed documents. If enabled, the signing certificate is included in the signed documents. If disabled, the signing certificate is not included in the signed documents. Regardless of this setting, the signing certificate is always available in the document's audit log page.
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Email Preferences
|
||||
description: Learn how to set the email preferences for your team account.
|
||||
---
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Email Preferences
|
||||
|
||||
Email preferences allow you to set the default settings when emailing documents to your recipients.
|
||||
|
||||
## Preferences
|
||||
|
||||
Email preferences can be set on either the organisation or team level.
|
||||
|
||||
By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time.
|
||||
|
||||
To access the preferences, navigate to either the organisation or teams settings page and click the **Email** tab under the **Preferences** section.
|
||||
|
||||

|
||||
|
||||
- **Default Email** - Use a custom email address when sending documents to your recipients. See [email domains](/users/email-domains) for more information.
|
||||
- **Reply To** - The email address that will be used in the "Reply To" field in emails
|
||||
- **Email Settings** - Which emails to send to recipients during document signing
|
||||
111
apps/documentation/pages/users/email-domains.mdx
Normal file
@ -0,0 +1,111 @@
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Email Domains
|
||||
|
||||
Email Domains allow you to send emails to recipients from your own domain instead of the default Documenso email address.
|
||||
|
||||
<Callout type="info">
|
||||
**Enterprise Only**: Email Domains is only available to Enterprise customers and custom plans
|
||||
</Callout>
|
||||
|
||||
## Creating Email Domains
|
||||
|
||||
Before setting up email domains, ensure you have:
|
||||
|
||||
- An Enterprise subscription
|
||||
- Access to your domain's DNS settings
|
||||
- Access to your Documenso organisation as an admin or manager
|
||||
|
||||
<Steps>
|
||||
|
||||
### Access Email Domains Settings
|
||||
|
||||
Navigate to your Organisation email domains settings page and click the "Add Email Domain" button.
|
||||
|
||||

|
||||
|
||||
### Configure DNS Records
|
||||
|
||||
After adding your domain, Documenso will provide you with the following required DNS records that need to be configured on your domain:
|
||||
|
||||
- **SPF Record**: Specifies which servers are authorized to send emails from your domain
|
||||
- **DKIM Record**: Provides email authentication and prevents tampering
|
||||
|
||||

|
||||
|
||||
<Callout type="info">
|
||||
If you already have an SPF record configured, you will need to update it to include Amazon SES as
|
||||
an authorized server instead of creating a new record.
|
||||
</Callout>
|
||||
|
||||
Configure these records in your domain's DNS settings according to their specific instructions.
|
||||
|
||||
### Verify Domain Configuration
|
||||
|
||||
Once you've added the DNS records, return to the Documenso email domains settings page and click the "Verify" button.
|
||||
This will trigger a verification process which will check if the DNS records are properly configured. If successful, the domain will be marked as "Active".
|
||||
|
||||

|
||||
|
||||
<Callout type="info">
|
||||
Please note that it may take up to 48 hours for the DNS records to propagate.
|
||||
</Callout>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Creating Emails
|
||||
|
||||
Once your email domain has been configured, you can create multiple email addresses which your members can use when sending documents to recipients.
|
||||
|
||||
<Steps>
|
||||
|
||||
### Select the Email Domain You Want to Use
|
||||
|
||||
Navigate to the email domains settings page and click "Manage" on the domain you want to use.
|
||||
|
||||

|
||||
|
||||
### Add a New Email
|
||||
|
||||
Click on the "Add Email" button to begin the setup process.
|
||||
|
||||

|
||||
|
||||
### Use Email
|
||||
|
||||
Once you have added an email, you can configure it to be the default email on either the:
|
||||
|
||||
- Organisation email preferences page
|
||||
- Team email preferences page
|
||||
|
||||
When a draft document is created, it will inherit the email configured on the team if set, otherwise it will inherit the email configured in the organisation.
|
||||
|
||||
You can also configure the email address directly on the document to override the default email if required.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Notes
|
||||
|
||||
- If you change the default email, it will not retroactively update any existing documents with the old default email.
|
||||
- If the email domain becomes invalid, all emails using that domain will fail to send.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**DNS Verification Fails**
|
||||
|
||||
- Double-check all DNS record values
|
||||
- Ensure records are added to the correct domain
|
||||
- Wait for DNS propagation (up to 48 hours)
|
||||
|
||||
**Emails Not Delivering**
|
||||
|
||||
- Check domain reputation and blacklist status
|
||||
- Verify SPF, DKIM, and DMARC records
|
||||
- Review bounce and spam reports
|
||||
|
||||
<Callout type="info">
|
||||
For additional support with Email Domains configuration, contact our support team at
|
||||
support@documenso.com.
|
||||
</Callout>
|
||||
|
After Width: | Height: | Size: 135 KiB |
BIN
apps/documentation/public/email-domains/email-domain-sync.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/documentation/public/webhook-images/test-webhooks-page.webp
Normal file
|
After Width: | Height: | Size: 98 KiB |
@ -1,19 +1,30 @@
|
||||
@import '@documenso/ui/styles/theme.css';
|
||||
|
||||
/* Inter Variable Fonts */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/public/fonts/inter-regular.ttf') format('ttf');
|
||||
/* font-weight: 400;
|
||||
src: url('/fonts/inter-variablefont_opsz,wght.ttf') format('truetype-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap; */
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Inter Italic Variable Fonts */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/inter-italic-variablefont_opsz,wght.ttf') format('truetype-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Caveat Variable Font */
|
||||
@font-face {
|
||||
font-family: 'Caveat';
|
||||
src: url('/public/fonts/caveat.ttf') format('ttf');
|
||||
/* font-weight: 400;
|
||||
src: url('/fonts/caveat-variablefont_wght.ttf') format('truetype-variations');
|
||||
font-weight: 400 600;
|
||||
font-style: normal;
|
||||
font-display: swap; */
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { User } from '@prisma/client';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } 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 { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminUserResetTwoFactorDialogProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const AdminUserResetTwoFactorDialog = ({
|
||||
className,
|
||||
user,
|
||||
}: AdminUserResetTwoFactorDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const [email, setEmail] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } =
|
||||
trpc.admin.user.resetTwoFactor.useMutation();
|
||||
|
||||
const onResetTwoFactor = async () => {
|
||||
try {
|
||||
await resetTwoFactor({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`2FA Reset`),
|
||||
description: _(msg`The user's two factor authentication has been reset successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
|
||||
.with(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
() => msg`You are not authorized to reset two factor authentcation for this user.`,
|
||||
)
|
||||
.otherwise(
|
||||
() => msg`An error occurred while resetting two factor authentication for the user.`,
|
||||
);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
|
||||
if (!newOpen) {
|
||||
setEmail('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="neutral"
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>Reset Two Factor Authentication</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Reset the users two factor authentication. This action is irreversible and will
|
||||
disable two factor authentication for the user.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trans>Reset 2FA</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>
|
||||
<Trans>Reset Two Factor Authentication</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>
|
||||
This action is irreversible. Please ensure you have informed the user before
|
||||
proceeding.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
To confirm, please enter the accounts email address <br />({user.email}).
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={email !== user.email}
|
||||
onClick={onResetTwoFactor}
|
||||
loading={isResettingTwoFactor}
|
||||
>
|
||||
<Trans>Reset 2FA</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,11 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
@ -31,6 +31,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@ -61,6 +62,8 @@ export const DocumentMoveToFolderDialog = ({
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const form = useForm<TMoveDocumentFormSchema>({
|
||||
resolver: zodResolver(ZMoveDocumentFormSchema),
|
||||
defaultValues: {
|
||||
@ -83,6 +86,7 @@ export const DocumentMoveToFolderDialog = ({
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId });
|
||||
}
|
||||
@ -123,6 +127,16 @@ export const DocumentMoveToFolderDialog = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`You are not allowed to move this document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while moving the document.`),
|
||||
@ -131,6 +145,10 @@ export const DocumentMoveToFolderDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFolders = folders?.data.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
@ -144,8 +162,18 @@ export const DocumentMoveToFolderDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
|
||||
<Input
|
||||
placeholder={_(msg`Search folders...`)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderId"
|
||||
@ -154,8 +182,9 @@ export const DocumentMoveToFolderDialog = ({
|
||||
<FormLabel>
|
||||
<Trans>Folder</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{isFoldersLoading ? (
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@ -170,10 +199,10 @@ export const DocumentMoveToFolderDialog = ({
|
||||
disabled={currentFolderId === null}
|
||||
>
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Root (No Folder)</Trans>
|
||||
<Trans>Home (No Folder)</Trans>
|
||||
</Button>
|
||||
|
||||
{folders?.data.map((folder) => (
|
||||
{filteredFolders?.map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
@ -186,6 +215,12 @@ export const DocumentMoveToFolderDialog = ({
|
||||
{folder.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{searchTerm && filteredFolders?.length === 0 && (
|
||||
<div className="text-muted-foreground px-2 py-2 text-center text-sm">
|
||||
<Trans>No folders found</Trans>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,17 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { FolderType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderPlusIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -34,26 +31,22 @@ import {
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZCreateFolderFormSchema = z.object({
|
||||
name: z.string().min(1, { message: 'Folder name is required' }),
|
||||
});
|
||||
|
||||
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||
|
||||
export type CreateFolderDialogProps = {
|
||||
export type FolderCreateDialogProps = {
|
||||
type: FolderType;
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
||||
@ -67,37 +60,21 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp
|
||||
|
||||
const onSubmit = async (data: TCreateFolderFormSchema) => {
|
||||
try {
|
||||
const newFolder = await createFolder({
|
||||
await createFolder({
|
||||
name: data.name,
|
||||
parentId: folderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
type,
|
||||
});
|
||||
|
||||
setIsCreateFolderOpen(false);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
await navigate(`${documentsPath}/f/${newFolder.id}`);
|
||||
|
||||
toast({
|
||||
description: 'Folder created successfully',
|
||||
description: t`Folder created successfully`,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||
toast({
|
||||
title: 'Failed to create folder',
|
||||
description: _(msg`This folder name is already taken.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Failed to create folder',
|
||||
description: _(msg`An unknown error occurred while creating the folder.`),
|
||||
title: t`Failed to create folder`,
|
||||
description: t`An unknown error occurred while creating the folder.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -113,48 +90,60 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp
|
||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" className="flex items-center space-x-2">
|
||||
<FolderPlusIcon className="h-4 w-4" />
|
||||
<span>Create Folder</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center"
|
||||
data-testid="folder-create-button"
|
||||
>
|
||||
<FolderPlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Create Folder</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Folder</DialogTitle>
|
||||
<DialogTitle>
|
||||
<Trans>Create New Folder</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a name for your new folder. Folders help you organise your documents.
|
||||
<Trans>Enter a name for your new folder. Folders help you organise your items.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Folder Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Folder" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Folder Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t`My Folder`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setIsCreateFolderOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setIsCreateFolderOpen(false)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">Create</Button>
|
||||
</DialogFooter>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@ -11,6 +10,7 @@ import { z } from 'zod';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -32,22 +32,22 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type FolderDeleteDialogProps = {
|
||||
folder: TFolderWithSubfolders | null;
|
||||
folder: TFolderWithSubfolders;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { t } = useLingui();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
|
||||
|
||||
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
|
||||
const deleteMessage = t`delete ${folder.name}`;
|
||||
|
||||
const ZDeleteFolderFormSchema = z.object({
|
||||
confirmText: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
|
||||
errorMap: () => ({ message: t`You must type '${deleteMessage}' to confirm` }),
|
||||
}),
|
||||
});
|
||||
|
||||
@ -61,8 +61,6 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
});
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
if (!folder) return;
|
||||
|
||||
try {
|
||||
await deleteFolder({
|
||||
id: folder.id,
|
||||
@ -71,15 +69,15 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
onOpenChange(false);
|
||||
|
||||
toast({
|
||||
title: 'Folder deleted successfully',
|
||||
title: t`Folder deleted successfully`,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
toast({
|
||||
title: 'Folder not found',
|
||||
description: _(msg`The folder you are trying to delete does not exist.`),
|
||||
title: t`Folder not found`,
|
||||
description: t`The folder you are trying to delete does not exist.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@ -87,8 +85,8 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Failed to delete folder',
|
||||
description: _(msg`An unknown error occurred while deleting the folder.`),
|
||||
title: t`Failed to delete folder`,
|
||||
description: t`An unknown error occurred while deleting the folder.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -104,53 +102,65 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Folder</DialogTitle>
|
||||
<DialogTitle>
|
||||
<Trans>Delete Folder</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this folder?
|
||||
{folder && folder._count.documents > 0 && (
|
||||
<span className="text-destructive mt-2 block">
|
||||
This folder contains {folder._count.documents} document(s). Deleting it will also
|
||||
delete all documents in the folder.
|
||||
</span>
|
||||
)}
|
||||
{folder && folder._count.subfolders > 0 && (
|
||||
<span className="text-destructive mt-2 block">
|
||||
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
|
||||
delete all subfolders and their contents.
|
||||
</span>
|
||||
)}
|
||||
<Trans>Are you sure you want to delete this folder?</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{(folder._count.documents > 0 ||
|
||||
folder._count.templates > 0 ||
|
||||
folder._count.subfolders > 0) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
This folder contains multiple items. Deleting it will remove all subfolders and move
|
||||
all nested documents and templates to the root folder.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing:{' '}
|
||||
<span className="font-sm text-destructive font-semibold">
|
||||
{deleteMessage}
|
||||
</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={deleteMessage} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing:{' '}
|
||||
<span className="font-sm text-destructive font-semibold">
|
||||
{deleteMessage}
|
||||
</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={deleteMessage} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon } from 'lucide-react';
|
||||
import { FolderIcon, HomeIcon, Search } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type FolderMoveDialogProps = {
|
||||
@ -48,9 +49,10 @@ export const FolderMoveDialog = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: FolderMoveDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||
|
||||
const form = useForm<TMoveFolderFormSchema>({
|
||||
@ -72,15 +74,15 @@ export const FolderMoveDialog = ({
|
||||
onOpenChange(false);
|
||||
|
||||
toast({
|
||||
title: 'Folder moved successfully',
|
||||
title: t`Folder moved successfully`,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
toast({
|
||||
title: 'Folder not found',
|
||||
description: _(msg`The folder you are trying to move does not exist.`),
|
||||
title: t`Folder not found`,
|
||||
description: t`The folder you are trying to move does not exist.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@ -88,8 +90,8 @@ export const FolderMoveDialog = ({
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Failed to move folder',
|
||||
description: _(msg`An unknown error occurred while moving the folder.`),
|
||||
title: t`Failed to move folder`,
|
||||
description: t`An unknown error occurred while moving the folder.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -98,69 +100,91 @@ export const FolderMoveDialog = ({
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
}
|
||||
}, [isOpen, form]);
|
||||
|
||||
// Filter out the current folder and only show folders of the same type
|
||||
// Filter out the current folder, only show folders of the same type, and filter by search term
|
||||
const filteredFolders = foldersData?.filter(
|
||||
(f) => f.id !== folder?.id && f.type === folder?.type,
|
||||
(f) =>
|
||||
f.id !== folder?.id &&
|
||||
f.type === folder?.type &&
|
||||
(searchTerm === '' || f.name.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Move Folder</DialogTitle>
|
||||
<DialogDescription>Select a destination for this folder.</DialogDescription>
|
||||
<DialogTitle>
|
||||
<Trans>Move Folder</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Select a destination for this folder.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetFolderId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={!field.value ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
disabled={!folder?.parentId}
|
||||
onClick={() => field.onChange(null)}
|
||||
>
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
Root
|
||||
</Button>
|
||||
|
||||
{filteredFolders &&
|
||||
filteredFolders.map((f) => (
|
||||
<Button
|
||||
key={f.id}
|
||||
type="button"
|
||||
disabled={f.id === folder?.parentId}
|
||||
variant={field.value === f.id ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
onClick={() => field.onChange(f.id)}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
{f.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
Move Folder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
|
||||
<Input
|
||||
placeholder={t`Search folders...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetFolderId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant={!field.value ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
disabled={!folder?.parentId}
|
||||
onClick={() => field.onChange(null)}
|
||||
>
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Home</Trans>
|
||||
</Button>
|
||||
|
||||
{filteredFolders &&
|
||||
filteredFolders.map((f) => (
|
||||
<Button
|
||||
key={f.id}
|
||||
type="button"
|
||||
disabled={f.id === folder?.parentId}
|
||||
variant={field.value === f.id ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
onClick={() => field.onChange(f.id)}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
{f.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Move</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
@ -14,6 +14,7 @@ import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@ -40,7 +41,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type FolderSettingsDialogProps = {
|
||||
export type FolderUpdateDialogProps = {
|
||||
folder: TFolderWithSubfolders | null;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@ -53,12 +54,8 @@ export const ZUpdateFolderFormSchema = z.object({
|
||||
|
||||
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
||||
|
||||
export const FolderSettingsDialog = ({
|
||||
folder,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: FolderSettingsDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { toast } = useToast();
|
||||
@ -84,7 +81,9 @@ export const FolderSettingsDialog = ({
|
||||
}, [folder, form]);
|
||||
|
||||
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
||||
if (!folder) return;
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateFolder({
|
||||
@ -96,7 +95,7 @@ export const FolderSettingsDialog = ({
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Folder updated successfully`),
|
||||
title: t`Folder updated successfully`,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
@ -105,7 +104,7 @@ export const FolderSettingsDialog = ({
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
toast({
|
||||
title: _(msg`Folder not found`),
|
||||
title: t`Folder not found`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -115,8 +114,12 @@ export const FolderSettingsDialog = ({
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Folder Settings</DialogTitle>
|
||||
<DialogDescription>Manage the settings for this folder.</DialogDescription>
|
||||
<DialogTitle>
|
||||
<Trans>Folder Settings</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Manage the settings for this folder.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
@ -126,7 +129,9 @@ export const FolderSettingsDialog = ({
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@ -141,19 +146,25 @@ export const FolderSettingsDialog = ({
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Visibility</FormLabel>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select visibility" />
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
Managers and above
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@ -163,7 +174,15 @@ export const FolderSettingsDialog = ({
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
@ -19,6 +19,7 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -46,6 +47,8 @@ import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { IndividualPersonalLayoutCheckoutButton } from '../general/billing-plans';
|
||||
|
||||
export type OrganisationCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
@ -59,10 +62,11 @@ export type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFo
|
||||
export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useSession();
|
||||
const { refreshSession, organisations } = useSession();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const actionSearchParam = searchParams?.get('action');
|
||||
|
||||
@ -83,7 +87,9 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
|
||||
const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation();
|
||||
|
||||
const { data: plansData } = trpc.billing.plans.get.useQuery();
|
||||
const { data: plansData } = trpc.enterprise.billing.plans.get.useQuery(undefined, {
|
||||
enabled: IS_BILLING_ENABLED(),
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
|
||||
try {
|
||||
@ -131,6 +137,13 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
form.reset();
|
||||
}, [open, form]);
|
||||
|
||||
const isIndividualPlan = (priceId: string) => {
|
||||
return (
|
||||
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.monthlyPrice?.id === priceId ||
|
||||
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.yearlyPrice?.id === priceId
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
@ -175,9 +188,15 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" onClick={() => setStep('create')}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
{isIndividualPlan(selectedPriceId) && isPersonalLayoutMode ? (
|
||||
<IndividualPersonalLayoutCheckoutButton priceId={selectedPriceId}>
|
||||
<Trans>Checkout</Trans>
|
||||
</IndividualPersonalLayoutCheckoutButton>
|
||||
) : (
|
||||
<Button type="submit" onClick={() => setStep('create')}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</>
|
||||
@ -304,7 +323,11 @@ const BillingPlanForm = ({
|
||||
}, [value]);
|
||||
|
||||
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
||||
const plan = dynamicPlans.find((plan) => plan[billingPeriod]?.id === value);
|
||||
const plan = dynamicPlans.find(
|
||||
(plan) =>
|
||||
// Purposely using the opposite billing period to get the correct plan.
|
||||
plan[billingPeriod === 'monthlyPrice' ? 'yearlyPrice' : 'monthlyPrice']?.id === value,
|
||||
);
|
||||
|
||||
setBillingPeriod(billingPeriod);
|
||||
|
||||
|
||||
@ -0,0 +1,243 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/create-organisation-email.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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';
|
||||
|
||||
type EmailDomain = {
|
||||
id: string;
|
||||
domain: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type OrganisationEmailCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
emailDomain: EmailDomain;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZCreateOrganisationEmailFormSchema = ZCreateOrganisationEmailRequestSchema.pick({
|
||||
emailName: true,
|
||||
email: true,
|
||||
// replyTo: true,
|
||||
});
|
||||
|
||||
type TCreateOrganisationEmailFormSchema = z.infer<typeof ZCreateOrganisationEmailFormSchema>;
|
||||
|
||||
export const OrganisationEmailCreateDialog = ({
|
||||
trigger,
|
||||
emailDomain,
|
||||
...props
|
||||
}: OrganisationEmailCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZCreateOrganisationEmailFormSchema),
|
||||
defaultValues: {
|
||||
emailName: '',
|
||||
email: '',
|
||||
// replyTo: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createOrganisationEmail, isPending } =
|
||||
trpc.enterprise.organisation.email.create.useMutation();
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const onFormSubmit = async (data: TCreateOrganisationEmailFormSchema) => {
|
||||
try {
|
||||
await createOrganisationEmail({
|
||||
emailDomainId: emailDomain.id,
|
||||
...data,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Email Created`,
|
||||
description: t`The organisation email has been created successfully.`,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(error);
|
||||
|
||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||
toast({
|
||||
title: t`Email already exists`,
|
||||
description: t`An email with this address already exists.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`An error occurred`,
|
||||
description: t`We encountered an error while creating the email. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
<Trans>Add Email</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Add Organisation Email</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Create a new email address for your organisation using the domain{' '}
|
||||
<span className="font-bold">{emailDomain.domain}</span>.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Display Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Support" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>The display name for this email address</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value.split('@')[0]}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value + '@' + emailDomain.domain);
|
||||
}}
|
||||
placeholder="support"
|
||||
/>
|
||||
<div className="bg-muted text-muted-foreground absolute bottom-0 right-0 top-0 flex items-center rounded-r-md border px-3 py-2 text-sm">
|
||||
@{emailDomain.domain}
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{!form.formState.errors.email && (
|
||||
<span className="text-foreground/50 text-xs font-normal">
|
||||
{field.value ? (
|
||||
field.value
|
||||
) : (
|
||||
<Trans>
|
||||
The part before the @ symbol (e.g., "support" for support@
|
||||
{emailDomain.domain})
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="replyTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply-To Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="noreply@example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Optional no-reply email address attached to emails. Leave blank to default
|
||||
to the organisation settings reply-to email.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="dialog-create-organisation-email-button"
|
||||
loading={isPending}
|
||||
>
|
||||
<Trans>Create Email</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
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';
|
||||
|
||||
export type OrganisationEmailDeleteDialogProps = {
|
||||
emailId: string;
|
||||
email: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const OrganisationEmailDeleteDialog = ({
|
||||
trigger,
|
||||
emailId,
|
||||
email,
|
||||
}: OrganisationEmailDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { mutateAsync: deleteEmail, isPending: isDeleting } =
|
||||
trpc.enterprise.organisation.email.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`You have successfully removed this email from the organisation.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to remove this email. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
<Trans>Delete email</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are about to remove the following email from{' '}
|
||||
<span className="font-semibold">{organisation.name}</span>.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription className="text-center font-semibold">{email}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<fieldset disabled={isDeleting}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={async () =>
|
||||
deleteEmail({
|
||||
emailId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateOrganisationEmailDomainRequestSchema } from '@documenso/trpc/server/enterprise-router/create-organisation-email-domain.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { OrganisationEmailDomainRecordContent } from './organisation-email-domain-records-dialog';
|
||||
|
||||
export type OrganisationEmailCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZCreateOrganisationEmailDomainFormSchema = ZCreateOrganisationEmailDomainRequestSchema.pick({
|
||||
domain: true,
|
||||
});
|
||||
|
||||
type TCreateOrganisationEmailDomainFormSchema = z.infer<
|
||||
typeof ZCreateOrganisationEmailDomainFormSchema
|
||||
>;
|
||||
|
||||
type DomainRecord = {
|
||||
name: string;
|
||||
value: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export const OrganisationEmailDomainCreateDialog = ({
|
||||
trigger,
|
||||
...props
|
||||
}: OrganisationEmailCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState<'domain' | 'verification'>('domain');
|
||||
const [recordsToAdd, setRecordsToAdd] = useState<DomainRecord[]>([]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZCreateOrganisationEmailDomainFormSchema),
|
||||
defaultValues: {
|
||||
domain: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createOrganisationEmail } =
|
||||
trpc.enterprise.organisation.emailDomain.create.useMutation();
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setStep('domain');
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const onFormSubmit = async ({ domain }: TCreateOrganisationEmailDomainFormSchema) => {
|
||||
try {
|
||||
const { records } = await createOrganisationEmail({
|
||||
domain,
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
setRecordsToAdd(records);
|
||||
setStep('verification');
|
||||
|
||||
toast({
|
||||
title: t`Domain Added`,
|
||||
description: t`DKIM records generated. Please add the DNS records to verify your domain.`,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
console.error(error);
|
||||
|
||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||
toast({
|
||||
title: t`Domain already in use`,
|
||||
description: t`Please try a different domain.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to add your domain. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
<Trans>Add Email Domain</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
{step === 'domain' ? (
|
||||
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Add Custom Email Domain</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Add a custom domain to send emails on behalf of your organisation. We'll generate
|
||||
DKIM records that you need to add to your DNS provider.
|
||||
</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="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Domain Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="example.com" className="bg-background" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Enter the domain you want to use for sending emails (without http:// or
|
||||
www)
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="dialog-create-organisation-email-button"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Generate DKIM Records</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
) : (
|
||||
<OrganisationEmailDomainRecordContent records={recordsToAdd} />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
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';
|
||||
|
||||
export type OrganisationEmailDomainDeleteDialogProps = {
|
||||
emailDomainId: string;
|
||||
emailDomain: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const OrganisationEmailDomainDeleteDialog = ({
|
||||
trigger,
|
||||
emailDomainId,
|
||||
emailDomain,
|
||||
}: OrganisationEmailDomainDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const deleteMessage = t`delete ${emailDomain}`;
|
||||
|
||||
const ZDeleteEmailDomainFormSchema = z.object({
|
||||
confirmText: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: t`You must type '${deleteMessage}' to confirm` }),
|
||||
}),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof ZDeleteEmailDomainFormSchema>>({
|
||||
resolver: zodResolver(ZDeleteEmailDomainFormSchema),
|
||||
defaultValues: {
|
||||
confirmText: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteEmailDomain, isPending: isDeleting } =
|
||||
trpc.enterprise.organisation.emailDomain.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`You have successfully removed this email domain from the organisation.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to remove this email domain. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
await deleteEmailDomain({
|
||||
emailDomainId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
<Trans>Delete email domain</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are about to remove the email domain{' '}
|
||||
<span className="font-semibold">{emailDomain}</span> from{' '}
|
||||
<span className="font-semibold">{organisation.name}</span>. All emails associated with
|
||||
this domain will be deleted.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing{' '}
|
||||
<span className="font-sm text-destructive font-semibold">
|
||||
{deleteMessage}
|
||||
</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={deleteMessage} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,139 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type OrganisationEmailDomainRecordsDialogProps = {
|
||||
trigger: React.ReactNode;
|
||||
records: DomainRecord[];
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
type DomainRecord = {
|
||||
name: string;
|
||||
value: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export const OrganisationEmailDomainRecordsDialog = ({
|
||||
trigger,
|
||||
records,
|
||||
...props
|
||||
}: OrganisationEmailDomainRecordsDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<OrganisationEmailDomainRecordContent records={records} />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganisationEmailDomainRecordContent = ({ records }: { records: DomainRecord[] }) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Verify Domain</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Add these DNS records to verify your domain ownership</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{records.map((record) => (
|
||||
<div className="space-y-4 rounded-md border p-4" key={record.name}>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Record Type</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input className="pr-12" disabled value={record.type} />
|
||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
||||
<CopyTextButton
|
||||
value={record.type}
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Record Name</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input className="pr-12" disabled value={record.name} />
|
||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
||||
<CopyTextButton
|
||||
value={record.name}
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Record Value</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input className="pr-12" disabled value={record.value} />
|
||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
||||
<CopyTextButton
|
||||
value={record.value}
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Once you update your DNS records, it may take up to 48 hours for it to be propogated.
|
||||
Once the DNS propagation is complete you will need to come back and press the "Sync"
|
||||
domains button
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
|
||||
import { ZUpdateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/update-organisation-email.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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';
|
||||
|
||||
export type OrganisationEmailUpdateDialogProps = {
|
||||
trigger: React.ReactNode;
|
||||
organisationEmail: TGetOrganisationEmailDomainResponse['emails'][number];
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZUpdateOrganisationEmailFormSchema = ZUpdateOrganisationEmailRequestSchema.pick({
|
||||
emailName: true,
|
||||
// replyTo: true,
|
||||
});
|
||||
|
||||
type ZUpdateOrganisationEmailSchema = z.infer<typeof ZUpdateOrganisationEmailFormSchema>;
|
||||
|
||||
export const OrganisationEmailUpdateDialog = ({
|
||||
trigger,
|
||||
organisationEmail,
|
||||
...props
|
||||
}: OrganisationEmailUpdateDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<ZUpdateOrganisationEmailSchema>({
|
||||
resolver: zodResolver(ZUpdateOrganisationEmailFormSchema),
|
||||
defaultValues: {
|
||||
emailName: organisationEmail.emailName,
|
||||
// replyTo: organisationEmail.replyTo ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisationEmail, isPending } =
|
||||
trpc.enterprise.organisation.email.update.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ emailName }: ZUpdateOrganisationEmailSchema) => {
|
||||
try {
|
||||
await updateOrganisationEmail({
|
||||
emailId: organisationEmail.id,
|
||||
emailName,
|
||||
// replyTo,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset({
|
||||
emailName: organisationEmail.emailName,
|
||||
// replyTo: organisationEmail.replyTo ?? undefined,
|
||||
});
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Update email</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are currently updating{' '}
|
||||
<span className="font-bold">{organisationEmail.email}</span>
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Display Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Support" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>The display name for this email address</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="replyTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply-To Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="noreply@example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Optional no-reply email address attached to emails. Leave blank to default
|
||||
to the organisation settings reply-to email.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isPending}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -13,7 +13,7 @@ import { z } from 'zod';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
@ -303,8 +303,8 @@ export const OrganisationMemberInviteDialog = ({
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Your plan does not support inviting members. Please upgrade or your plan or
|
||||
contact sales at <a href="mailto:support@documenso.com">support@documenso.com</a>{' '}
|
||||
if you would like to discuss your options.
|
||||
contact sales at <a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a> if you
|
||||
would like to discuss your options.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@ -12,7 +12,11 @@ import type { z } from 'zod';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
IS_BILLING_ENABLED,
|
||||
NEXT_PUBLIC_WEBAPP_URL,
|
||||
SUPPORT_EMAIL,
|
||||
} from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types';
|
||||
@ -193,8 +197,8 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
You have reached the maximum number of teams for your plan. Please contact sales
|
||||
at <a href="mailto:support@documenso.com">support@documenso.com</a> if you would
|
||||
like to adjust your plan.
|
||||
at <a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a> if you would like to
|
||||
adjust your plan.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@ -4,7 +4,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -39,6 +41,7 @@ import {
|
||||
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 { useCurrentTeam } from '~/providers/team';
|
||||
@ -140,8 +143,28 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
{match(step)
|
||||
.with('SELECT', () => (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<DialogTitle className="flex flex-row items-center">
|
||||
<Trans>Add members</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-xs">
|
||||
<Trans>
|
||||
To be able to add members to a team, you must first add them to the
|
||||
organisation. For more information, please see the{' '}
|
||||
<Link
|
||||
to="https://docs.documenso.com/users/organisations/members"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-documenso-700 hover:text-documenso-600 hover:underline"
|
||||
>
|
||||
documentation
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
|
||||
@ -8,6 +8,7 @@ import { useNavigate } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -23,18 +24,21 @@ import {
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type TemplateCreateDialogProps = {
|
||||
templateRootPath: string;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCreateDialogProps) => {
|
||||
export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user } = useSession();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||
|
||||
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
||||
@ -66,7 +70,7 @@ export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCre
|
||||
|
||||
setShowTemplateCreateDialog(false);
|
||||
|
||||
await navigate(`${templateRootPath}/${id}/edit`);
|
||||
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
|
||||
@ -1,165 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderPlusIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
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 { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZCreateFolderFormSchema = z.object({
|
||||
name: z.string().min(1, { message: 'Folder name is required' }),
|
||||
});
|
||||
|
||||
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||
|
||||
export type TemplateFolderCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const TemplateFolderCreateDialog = ({
|
||||
trigger,
|
||||
...props
|
||||
}: TemplateFolderCreateDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
||||
|
||||
const form = useForm<TCreateFolderFormSchema>({
|
||||
resolver: zodResolver(ZCreateFolderFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TCreateFolderFormSchema) => {
|
||||
try {
|
||||
const newFolder = await createFolder({
|
||||
name: data.name,
|
||||
parentId: folderId,
|
||||
type: FolderType.TEMPLATE,
|
||||
});
|
||||
|
||||
setIsCreateFolderOpen(false);
|
||||
|
||||
toast({
|
||||
description: _(msg`Folder created successfully`),
|
||||
});
|
||||
|
||||
const templatesPath = formatTemplatesPath(team.url);
|
||||
|
||||
void navigate(`${templatesPath}/f/${newFolder.id}`);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||
toast({
|
||||
title: _(msg`Failed to create folder`),
|
||||
description: _(msg`This folder name is already taken.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Failed to create folder`),
|
||||
description: _(msg`An unknown error occurred while creating the folder.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreateFolderOpen) {
|
||||
form.reset();
|
||||
}
|
||||
}, [isCreateFolderOpen, form]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" className="flex items-center space-x-2">
|
||||
<FolderPlusIcon className="h-4 w-4" />
|
||||
<span>Create Folder</span>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a name for your new folder. Folders help you organise your templates.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Folder Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Folder" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setIsCreateFolderOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Create</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,163 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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';
|
||||
|
||||
export type TemplateFolderDeleteDialogProps = {
|
||||
folder: TFolderWithSubfolders | null;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const TemplateFolderDeleteDialog = ({
|
||||
folder,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: TemplateFolderDeleteDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
|
||||
|
||||
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
|
||||
|
||||
const ZDeleteFolderFormSchema = z.object({
|
||||
confirmText: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
|
||||
}),
|
||||
});
|
||||
|
||||
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
|
||||
|
||||
const form = useForm<TDeleteFolderFormSchema>({
|
||||
resolver: zodResolver(ZDeleteFolderFormSchema),
|
||||
defaultValues: {
|
||||
confirmText: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
if (!folder) return;
|
||||
|
||||
try {
|
||||
await deleteFolder({
|
||||
id: folder.id,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
toast({
|
||||
title: 'Folder deleted successfully',
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
toast({
|
||||
title: 'Folder not found',
|
||||
description: _(msg`The folder you are trying to delete does not exist.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Failed to delete folder',
|
||||
description: _(msg`An unknown error occurred while deleting the folder.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this folder?
|
||||
{folder && folder._count.documents > 0 && (
|
||||
<span className="text-destructive mt-2 block">
|
||||
This folder contains {folder._count.documents} document(s). Deleting it will also
|
||||
delete all documents in the folder.
|
||||
</span>
|
||||
)}
|
||||
{folder && folder._count.subfolders > 0 && (
|
||||
<span className="text-destructive mt-2 block">
|
||||
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
|
||||
delete all subfolders and their contents.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing:{' '}
|
||||
<span className="font-sm text-destructive font-semibold">
|
||||
{deleteMessage}
|
||||
</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={deleteMessage} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,175 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type TemplateFolderMoveDialogProps = {
|
||||
foldersData: TFolderWithSubfolders[] | undefined;
|
||||
folder: TFolderWithSubfolders | null;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZMoveFolderFormSchema = z.object({
|
||||
targetFolderId: z.string().optional(),
|
||||
});
|
||||
|
||||
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
|
||||
|
||||
export const TemplateFolderMoveDialog = ({
|
||||
foldersData,
|
||||
folder,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: TemplateFolderMoveDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||
|
||||
const form = useForm<TMoveFolderFormSchema>({
|
||||
resolver: zodResolver(ZMoveFolderFormSchema),
|
||||
defaultValues: {
|
||||
targetFolderId: folder?.parentId ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
||||
if (!folder) return;
|
||||
|
||||
try {
|
||||
await moveFolder({
|
||||
id: folder.id,
|
||||
parentId: targetFolderId ?? '',
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
toast({
|
||||
title: 'Folder moved successfully',
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
toast({
|
||||
title: 'Folder not found',
|
||||
description: _(msg`The folder you are trying to move does not exist.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Failed to move folder',
|
||||
description: _(msg`An unknown error occurred while moving the folder.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
}
|
||||
}, [isOpen, form]);
|
||||
|
||||
// Filter out the current folder and only show folders of the same type
|
||||
const filteredFolders = foldersData?.filter(
|
||||
(f) => f.id !== folder?.id && f.type === folder?.type,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Move Folder</DialogTitle>
|
||||
<DialogDescription>Select a destination for this folder.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetFolderId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={!field.value ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
disabled={!folder?.parentId}
|
||||
onClick={() => field.onChange(undefined)}
|
||||
>
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
Root
|
||||
</Button>
|
||||
|
||||
{filteredFolders &&
|
||||
filteredFolders.map((f) => (
|
||||
<Button
|
||||
key={f.id}
|
||||
type="button"
|
||||
disabled={f.id === folder?.parentId}
|
||||
variant={field.value === f.id ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
onClick={() => field.onChange(f.id)}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
{f.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
form.formState.isSubmitting ||
|
||||
form.getValues('targetFolderId') === folder?.parentId
|
||||
}
|
||||
>
|
||||
Move Folder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,176 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type TemplateFolderSettingsDialogProps = {
|
||||
folder: TFolderWithSubfolders | null;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const ZUpdateFolderFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
||||
});
|
||||
|
||||
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
||||
|
||||
export const TemplateFolderSettingsDialog = ({
|
||||
folder,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: TemplateFolderSettingsDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const isTeamContext = !!team;
|
||||
const isTemplateFolder = folder?.type === FolderType.TEMPLATE;
|
||||
|
||||
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
||||
resolver: zodResolver(ZUpdateFolderFormSchema),
|
||||
defaultValues: {
|
||||
name: folder?.name ?? '',
|
||||
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (folder) {
|
||||
form.reset({
|
||||
name: folder.name,
|
||||
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
|
||||
});
|
||||
}
|
||||
}, [folder, form]);
|
||||
|
||||
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
||||
if (!folder) return;
|
||||
|
||||
try {
|
||||
await updateFolder({
|
||||
id: folder.id,
|
||||
name: data.name,
|
||||
visibility:
|
||||
isTeamContext && !isTemplateFolder
|
||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
||||
: DocumentVisibility.EVERYONE,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Folder updated successfully`),
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
toast({
|
||||
title: _(msg`Folder not found`),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Folder Settings</DialogTitle>
|
||||
<DialogDescription>Manage the settings for this folder.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isTeamContext && !isTemplateFolder && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Visibility</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select visibility" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
Managers and above
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,11 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
@ -31,6 +31,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@ -59,9 +60,12 @@ export function TemplateMoveToFolderDialog({
|
||||
}: TemplateMoveToFolderDialogProps) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const form = useForm<TMoveTemplateFormSchema>({
|
||||
resolver: zodResolver(ZMoveTemplateFormSchema),
|
||||
defaultValues: {
|
||||
@ -84,6 +88,7 @@ export function TemplateMoveToFolderDialog({
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId ?? null });
|
||||
}
|
||||
@ -132,6 +137,10 @@ export function TemplateMoveToFolderDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFolders = folders?.data?.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
@ -145,6 +154,16 @@ export function TemplateMoveToFolderDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
|
||||
<Input
|
||||
placeholder={_(msg`Search folders...`)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
|
||||
<FormField
|
||||
@ -155,8 +174,9 @@ export function TemplateMoveToFolderDialog({
|
||||
<FormLabel>
|
||||
<Trans>Folder</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{isFoldersLoading ? (
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@ -171,10 +191,10 @@ export function TemplateMoveToFolderDialog({
|
||||
disabled={currentFolderId === null}
|
||||
>
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Root (No Folder)</Trans>
|
||||
<Trans>Home (No Folder)</Trans>
|
||||
</Button>
|
||||
|
||||
{folders?.data?.map((folder) => (
|
||||
{filteredFolders?.map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
@ -187,6 +207,12 @@ export function TemplateMoveToFolderDialog({
|
||||
{folder.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{searchTerm && filteredFolders?.length === 0 && (
|
||||
<div className="text-muted-foreground px-2 py-2 text-center text-sm">
|
||||
<Trans>No folders found</Trans>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -15,6 +15,7 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||
isTemplateRecipientEmailPlaceholder,
|
||||
} from '@documenso/lib/constants/template';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
@ -279,7 +280,11 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={recipients[index].email || _(msg`Email`)}
|
||||
placeholder={
|
||||
isTemplateRecipientEmailPlaceholder(field.value)
|
||||
? ''
|
||||
: _(msg`Email`)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@ -484,6 +489,7 @@ export function TemplateUseDialog({
|
||||
|
||||
<input
|
||||
type="file"
|
||||
data-testid="template-use-dialog-file-input"
|
||||
className="absolute h-full w-full opacity-0"
|
||||
accept=".pdf,application/pdf"
|
||||
onChange={(e) => {
|
||||
|
||||
170
apps/remix/app/components/dialogs/webhook-test-dialog.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Webhook } from '@prisma/client';
|
||||
import { WebhookTriggerEvents } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type WebhookTestDialogProps = {
|
||||
webhook: Pick<Webhook, 'id' | 'webhookUrl' | 'eventTriggers'>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const ZTestWebhookFormSchema = z.object({
|
||||
event: z.nativeEnum(WebhookTriggerEvents),
|
||||
});
|
||||
|
||||
type TTestWebhookFormSchema = z.infer<typeof ZTestWebhookFormSchema>;
|
||||
|
||||
export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: testWebhook } = trpc.webhook.testWebhook.useMutation();
|
||||
|
||||
const form = useForm<TTestWebhookFormSchema>({
|
||||
resolver: zodResolver(ZTestWebhookFormSchema),
|
||||
defaultValues: {
|
||||
event: webhook.eventTriggers[0],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async ({ event }: TTestWebhookFormSchema) => {
|
||||
try {
|
||||
await testWebhook({
|
||||
id: webhook.id,
|
||||
event,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Test webhook sent`),
|
||||
description: _(msg`The test webhook has been successfully sent to your endpoint.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: _(msg`Test webhook failed`),
|
||||
description: _(
|
||||
msg`We encountered an error while sending the test webhook. Please check your endpoint and try again.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Test Webhook</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Send a test webhook with sample data to verify your integration is working correctly.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="event"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Event Type</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an event type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{webhook.eventTriggers.map((event) => (
|
||||
<SelectItem key={event} value={event}>
|
||||
{toFriendlyWebhookEventName(event)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="rounded-md border p-4">
|
||||
<h4 className="mb-2 text-sm font-medium">
|
||||
<Trans>Webhook URL</Trans>
|
||||
</h4>
|
||||
<p className="text-muted-foreground break-all text-sm">{webhook.webhookUrl}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Send Test Webhook</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
@ -48,7 +48,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
documentData: DocumentData;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
metadata?: DocumentMeta | null;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhiteLabelling?: boolean;
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -32,7 +32,14 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
|
||||
|
||||
export type EmbedDocumentFieldsProps = {
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
metadata?: Pick<
|
||||
DocumentMeta,
|
||||
| 'timezone'
|
||||
| 'dateFormat'
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
> | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
@ -3,7 +3,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import {
|
||||
type DocumentData,
|
||||
type Field,
|
||||
@ -50,7 +50,7 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: DocumentField[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
metadata?: DocumentMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhitelabelling?: boolean;
|
||||
|
||||
@ -8,17 +8,24 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
isValidLanguageCode,
|
||||
} from '@documenso/lib/constants/i18n';
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import {
|
||||
type TDocumentMetaDateFormat,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
} from '@documenso/trpc/server/document-router/schema';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -44,8 +51,11 @@ import {
|
||||
export type TDocumentPreferencesFormSchema = {
|
||||
documentVisibility: DocumentVisibility | null;
|
||||
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
|
||||
documentTimezone: string | null;
|
||||
documentDateFormat: TDocumentMetaDateFormat | null;
|
||||
includeSenderDetails: boolean | null;
|
||||
includeSigningCertificate: boolean | null;
|
||||
includeAuditLog: boolean | null;
|
||||
signatureTypes: DocumentSignatureType[];
|
||||
};
|
||||
|
||||
@ -53,8 +63,11 @@ type SettingsSubset = Pick<
|
||||
TeamGlobalSettings,
|
||||
| 'documentVisibility'
|
||||
| 'documentLanguage'
|
||||
| 'documentTimezone'
|
||||
| 'documentDateFormat'
|
||||
| 'includeSenderDetails'
|
||||
| 'includeSigningCertificate'
|
||||
| 'includeAuditLog'
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
@ -81,8 +94,11 @@ export const DocumentPreferencesForm = ({
|
||||
const ZDocumentPreferencesFormSchema = z.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
||||
documentTimezone: z.string().nullable(),
|
||||
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
|
||||
includeSenderDetails: z.boolean().nullable(),
|
||||
includeSigningCertificate: z.boolean().nullable(),
|
||||
includeAuditLog: z.boolean().nullable(),
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
@ -94,8 +110,12 @@ export const DocumentPreferencesForm = ({
|
||||
documentLanguage: isValidLanguageCode(settings.documentLanguage)
|
||||
? settings.documentLanguage
|
||||
: null,
|
||||
documentTimezone: settings.documentTimezone,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
@ -124,7 +144,10 @@ export const DocumentPreferencesForm = ({
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="document-visibility-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@ -171,7 +194,10 @@ export const DocumentPreferencesForm = ({
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="document-language-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@ -199,6 +225,72 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentDateFormat"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Default Date Format</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger data-testid="document-date-format-trigger">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{DATE_FORMATS.map((format) => (
|
||||
<SelectItem key={format.key} value={format.value}>
|
||||
{format.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentTimezone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Default Time Zone</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Combobox
|
||||
triggerPlaceholder={
|
||||
canInherit ? t`Inherit from organisation` : t`Local timezone`
|
||||
}
|
||||
placeholder={t`Select a time zone`}
|
||||
options={TIME_ZONES}
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
testId="document-timezone-trigger"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signatureTypes"
|
||||
@ -222,7 +314,7 @@ export const DocumentPreferencesForm = ({
|
||||
emptySelectionPlaceholder={
|
||||
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
||||
}
|
||||
testId="signature-types-combobox"
|
||||
testId="signature-types-trigger"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -257,7 +349,10 @@ export const DocumentPreferencesForm = ({
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="include-sender-details-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@ -325,7 +420,10 @@ export const DocumentPreferencesForm = ({
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="include-signing-certificate-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@ -358,6 +456,56 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeAuditLog"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Include the Audit Logs in the Document</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether the audit logs will be included in the document when it is
|
||||
downloaded. The audit logs can still be downloaded from the logs page
|
||||
separately.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
|
||||
238
apps/remix/app/components/forms/email-preferences-form.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { FROM_ADDRESS } from '@documenso/lib/constants/email';
|
||||
import {
|
||||
DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||
ZDocumentEmailSettingsSchema,
|
||||
} from '@documenso/lib/types/document-email';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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';
|
||||
|
||||
const ZEmailPreferencesFormSchema = z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.string().email().nullable(),
|
||||
// emailReplyToName: z.string(),
|
||||
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
|
||||
});
|
||||
|
||||
export type TEmailPreferencesFormSchema = z.infer<typeof ZEmailPreferencesFormSchema>;
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
TeamGlobalSettings,
|
||||
'emailId' | 'emailReplyTo' | 'emailDocumentSettings'
|
||||
>;
|
||||
|
||||
export type EmailPreferencesFormProps = {
|
||||
settings: SettingsSubset;
|
||||
canInherit: boolean;
|
||||
onFormSubmit: (data: TEmailPreferencesFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
export const EmailPreferencesForm = ({
|
||||
settings,
|
||||
onFormSubmit,
|
||||
canInherit,
|
||||
}: EmailPreferencesFormProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const form = useForm<TEmailPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
emailId: settings.emailId,
|
||||
emailReplyTo: settings.emailReplyTo,
|
||||
// emailReplyToName: settings.emailReplyToName,
|
||||
emailDocumentSettings: settings.emailDocumentSettings,
|
||||
},
|
||||
resolver: zodResolver(ZEmailPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const { data: emailData, isLoading: isLoadingEmails } =
|
||||
trpc.enterprise.organisation.email.find.useQuery({
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
const emails = emailData?.data || [];
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-2xl flex-col gap-y-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{organisation.organisationClaim.flags.emailDomains && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Email</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger loading={isLoadingEmails}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{emails.map((email) => (
|
||||
<SelectItem key={email.id} value={email.id}>
|
||||
{email.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<SelectItem value={'-1'}>
|
||||
{canInherit ? <Trans>Inherit from organisation</Trans> : FROM_ADDRESS}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The default email to use when sending emails to recipients</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailReplyTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply to email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(value) => field.onChange(value.target.value || null)}
|
||||
placeholder="noreply@example.com"
|
||||
type="email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
The email address which will show up in the "Reply To" field in emails
|
||||
</Trans>
|
||||
|
||||
{canInherit && (
|
||||
<span>
|
||||
{'. '}
|
||||
<Trans>Leave blank to inherit from the organisation.</Trans>
|
||||
</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="emailReplyToName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply to name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailDocumentSettings"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Email Settings</Trans>
|
||||
</FormLabel>
|
||||
{canInherit && (
|
||||
<Select
|
||||
value={field.value === null ? 'INHERIT' : 'CONTROLLED'}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(
|
||||
value === 'CONTROLLED' ? DEFAULT_DOCUMENT_EMAIL_SETTINGS : null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value={'INHERIT'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value={'CONTROLLED'}>
|
||||
<Trans>Override organisation settings</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{field.value && (
|
||||
<div className="space-y-2 rounded-md border p-4">
|
||||
<DocumentEmailCheckboxes
|
||||
value={field.value ?? DEFAULT_DOCUMENT_EMAIL_SETTINGS}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the default email settings when new documents or templates are created
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
138
apps/remix/app/components/forms/support-ticket-form.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZSupportTicketSchema = z.object({
|
||||
subject: z.string().min(3, 'Subject is required'),
|
||||
message: z.string().min(10, 'Message must be at least 10 characters'),
|
||||
});
|
||||
|
||||
type TSupportTicket = z.infer<typeof ZSupportTicketSchema>;
|
||||
|
||||
export type SupportTicketFormProps = {
|
||||
organisationId: string;
|
||||
teamId?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const SupportTicketForm = ({
|
||||
organisationId,
|
||||
teamId,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: SupportTicketFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: submitSupportTicket, isPending } =
|
||||
trpc.profile.submitSupportTicket.useMutation();
|
||||
|
||||
const form = useForm<TSupportTicket>({
|
||||
resolver: zodResolver(ZSupportTicketSchema),
|
||||
defaultValues: {
|
||||
subject: '',
|
||||
message: '',
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = form.formState.isLoading || isPending;
|
||||
|
||||
const onSubmit = async (data: TSupportTicket) => {
|
||||
const { subject, message } = data;
|
||||
|
||||
try {
|
||||
await submitSupportTicket({
|
||||
subject,
|
||||
message,
|
||||
organisationId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Support ticket created`,
|
||||
description: t`Your support request has been submitted. We'll get back to you soon!`,
|
||||
});
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Failed to create support ticket`,
|
||||
description: t`An error occurred. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={isLoading} className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Subject</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Message</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={5} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex flex-row gap-2">
|
||||
<Button type="submit" size="sm" loading={isLoading}>
|
||||
<Trans>Submit</Trans>
|
||||
</Button>
|
||||
{onClose && (
|
||||
<Button variant="outline" size="sm" type="button" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||
|
||||
import type { GetMonthlyActiveUsersResult } from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
|
||||
export type MonthlyActiveUsersChartProps = {
|
||||
className?: string;
|
||||
title: string;
|
||||
cummulative?: boolean;
|
||||
data: GetMonthlyActiveUsersResult;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps<ValueType, NameType>) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
|
||||
<p>{label}</p>
|
||||
<p className="text-documenso">
|
||||
{payload[0].name === 'cume_count' ? 'Cumulative MAU' : 'Monthly Active Users'}:{' '}
|
||||
<span className="text-black">{Number(payload[0].value).toLocaleString('en-US')}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const MonthlyActiveUsersChart = ({
|
||||
className,
|
||||
data,
|
||||
title,
|
||||
cummulative = false,
|
||||
}: MonthlyActiveUsersChartProps) => {
|
||||
const formattedData = [...data].reverse().map(({ month, count, cume_count }) => {
|
||||
return {
|
||||
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('MMM yyyy'),
|
||||
count: Number(count),
|
||||
cume_count: Number(cume_count),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
|
||||
<div className="mb-6 flex px-4">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={formattedData}>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'hsl(var(--primary) / 10%)' }} />
|
||||
|
||||
<Bar
|
||||
dataKey={cummulative ? 'cume_count' : 'count'}
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
label={cummulative ? 'Cumulative MAU' : 'Monthly Active Users'}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,11 +1,13 @@
|
||||
import { type HTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import { ReadStatus } from '@prisma/client';
|
||||
import { InboxIcon, MenuIcon, SearchIcon } from 'lucide-react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -28,6 +30,15 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
|
||||
{
|
||||
readStatus: ReadStatus.NOT_OPENED,
|
||||
},
|
||||
{
|
||||
// refetchInterval: 30000, // Refetch every 30 seconds
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
setScrollY(window.scrollY);
|
||||
@ -61,12 +72,11 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
<Link to="/inbox" className="relative block h-10 w-10">
|
||||
<InboxIcon className="text-muted-foreground hover:text-foreground h-5 w-5 flex-shrink-0 transition-colors" />
|
||||
|
||||
{/* Todo: Add counter. */}
|
||||
{/* {unreadCount > 0 && (
|
||||
<span className="bg-muted text-muted-foreground absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full text-[10px]">
|
||||
1
|
||||
{unreadCountData && unreadCountData.count > 0 && (
|
||||
<span className="bg-primary text-primary-foreground absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold">
|
||||
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
||||
</span>
|
||||
)} */}
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
|
||||
@ -2,12 +2,14 @@ import { useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ReadStatus } from '@prisma/client';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import LogoImage from '@documenso/assets/logo.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
|
||||
@ -25,6 +27,15 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
|
||||
const currentTeam = useOptionalCurrentTeam();
|
||||
|
||||
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
|
||||
{
|
||||
readStatus: ReadStatus.NOT_OPENED,
|
||||
},
|
||||
{
|
||||
// refetchInterval: 30000, // Refetch every 30 seconds
|
||||
},
|
||||
);
|
||||
|
||||
const handleMenuItemClick = () => {
|
||||
onMenuOpenChange?.(false);
|
||||
};
|
||||
@ -52,11 +63,11 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
return [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
label: t`Documents`,
|
||||
text: t`Documents`,
|
||||
},
|
||||
{
|
||||
href: `/t/${teamUrl}/templates`,
|
||||
label: t`Templates`,
|
||||
text: t`Templates`,
|
||||
},
|
||||
{
|
||||
href: '/inbox',
|
||||
@ -86,11 +97,16 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
{menuNavigationLinks.map(({ href, text }) => (
|
||||
<Link
|
||||
key={href}
|
||||
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||
className="text-foreground hover:text-foreground/80 flex items-center gap-2 text-2xl font-semibold"
|
||||
to={href}
|
||||
onClick={() => handleMenuItemClick()}
|
||||
>
|
||||
{text}
|
||||
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
|
||||
<span className="bg-primary text-primary-foreground flex h-6 min-w-[1.5rem] items-center justify-center rounded-full px-1.5 text-xs font-semibold">
|
||||
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
|
||||
@ -10,7 +10,9 @@ import { useForm } from 'react-hook-form';
|
||||
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||
@ -49,8 +51,12 @@ export type BillingPlansProps = {
|
||||
export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const { organisations } = useSession();
|
||||
|
||||
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const pricesToDisplay = useMemo(() => {
|
||||
const prices = [];
|
||||
|
||||
@ -126,12 +132,18 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<BillingDialog
|
||||
priceId={price.id}
|
||||
planName={price.product.name}
|
||||
memberCount={price.memberCount}
|
||||
claim={price.claim}
|
||||
/>
|
||||
{isPersonalLayoutMode && price.claim === INTERNAL_CLAIM_ID.INDIVIDUAL ? (
|
||||
<IndividualPersonalLayoutCheckoutButton priceId={price.id}>
|
||||
<Trans>Subscribe</Trans>
|
||||
</IndividualPersonalLayoutCheckoutButton>
|
||||
) : (
|
||||
<BillingDialog
|
||||
priceId={price.id}
|
||||
planName={price.product.name}
|
||||
memberCount={price.memberCount}
|
||||
claim={price.claim}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</MotionCard>
|
||||
))}
|
||||
@ -174,7 +186,7 @@ const BillingDialog = ({
|
||||
});
|
||||
|
||||
const { mutateAsync: createSubscription, isPending: isCreatingSubscription } =
|
||||
trpc.billing.subscription.create.useMutation();
|
||||
trpc.enterprise.billing.subscription.create.useMutation();
|
||||
|
||||
const { mutateAsync: createOrganisation, isPending: isCreatingOrganisation } =
|
||||
trpc.organisation.create.useMutation();
|
||||
@ -315,3 +327,48 @@ const BillingDialog = ({
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom checkout button for individual organisations in personal layout mode.
|
||||
*
|
||||
* This is so they don't create an additional organisation which is not needed since
|
||||
* it will clutter up the UI for them with unnecessary organisations.
|
||||
*/
|
||||
export const IndividualPersonalLayoutCheckoutButton = ({
|
||||
priceId,
|
||||
children,
|
||||
}: {
|
||||
priceId: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { organisations } = useSession();
|
||||
|
||||
const { mutateAsync: createSubscription, isPending } =
|
||||
trpc.enterprise.billing.subscription.create.useMutation();
|
||||
|
||||
const onSubscribeClick = async () => {
|
||||
try {
|
||||
const createSubscriptionResponse = await createSubscription({
|
||||
organisationId: organisations[0].id,
|
||||
priceId,
|
||||
isPersonalLayoutMode: true,
|
||||
});
|
||||
|
||||
window.location.href = createSubscriptionResponse.redirectUrl;
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`An error occurred while trying to create a checkout session.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button loading={isPending} onClick={() => void onSubscribeClick()}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@ -10,6 +10,7 @@ import { useRevalidator } from 'react-router';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
||||
import { AUTO_SIGNABLE_FIELD_TYPES } from '@documenso/lib/constants/autosign';
|
||||
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -30,13 +31,6 @@ import { DocumentSigningDisclosure } from '~/components/general/document-signing
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||
|
||||
const AUTO_SIGNABLE_FIELD_TYPES: string[] = [
|
||||
FieldType.NAME,
|
||||
FieldType.INITIALS,
|
||||
FieldType.EMAIL,
|
||||
FieldType.DATE,
|
||||
];
|
||||
|
||||
// The action auth types that are not allowed to be auto signed
|
||||
//
|
||||
// Reasoning: If the action auth is a passkey or 2FA, it's likely that the owner of the document
|
||||
|
||||
@ -17,6 +17,7 @@ import type {
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
@ -276,7 +277,14 @@ export const DocumentSigningCheckboxField = ({
|
||||
{validationSign?.label} {checkboxValidationLength}
|
||||
</FieldToolTip>
|
||||
)}
|
||||
<div className="z-50 my-0.5 flex flex-col gap-y-1">
|
||||
<div
|
||||
className={cn(
|
||||
'z-50 my-0.5 flex gap-1',
|
||||
parsedFieldMeta.direction === 'horizontal'
|
||||
? 'flex-row flex-wrap'
|
||||
: 'flex-col gap-y-1',
|
||||
)}
|
||||
>
|
||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||
const itemValue = item.value || `empty-value-${item.id}`;
|
||||
|
||||
@ -286,6 +294,7 @@ export const DocumentSigningCheckboxField = ({
|
||||
className="h-3 w-3"
|
||||
id={`checkbox-${field.id}-${item.id}`}
|
||||
checked={checkedValues.includes(itemValue)}
|
||||
disabled={isReadOnly}
|
||||
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
|
||||
/>
|
||||
{!item.value.includes('empty-value-') && item.value && (
|
||||
@ -304,7 +313,12 @@ export const DocumentSigningCheckboxField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<div className="my-0.5 flex flex-col gap-y-1">
|
||||
<div
|
||||
className={cn(
|
||||
'my-0.5 flex gap-1',
|
||||
parsedFieldMeta.direction === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col gap-y-1',
|
||||
)}
|
||||
>
|
||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||
const itemValue = item.value || `empty-value-${item.id}`;
|
||||
|
||||
@ -314,7 +328,7 @@ export const DocumentSigningCheckboxField = ({
|
||||
className="h-3 w-3"
|
||||
id={`checkbox-${field.id}-${item.id}`}
|
||||
checked={parsedCheckedValues.includes(itemValue)}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isReadOnly}
|
||||
onCheckedChange={() => void handleCheckboxOptionClick(item)}
|
||||
/>
|
||||
{!item.value.includes('empty-value-') && item.value && (
|
||||
|
||||
@ -131,7 +131,12 @@ export const DocumentSigningFieldContainer = ({
|
||||
|
||||
return (
|
||||
<div className={cn('[container-type:size]')}>
|
||||
<FieldRootContainer color={RECIPIENT_COLOR_STYLES.green} field={field}>
|
||||
<FieldRootContainer
|
||||
color={
|
||||
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
|
||||
}
|
||||
field={field}
|
||||
>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
type="submit"
|
||||
@ -140,14 +145,6 @@ export const DocumentSigningFieldContainer = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{readOnlyField && (
|
||||
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
|
||||
<span className="bg-foreground/50 text-background rounded-xl p-2">
|
||||
<Trans>Read only field</Trans>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
||||
|
||||
@ -12,7 +12,7 @@ export const DocumentSigningFieldsLoader = () => {
|
||||
|
||||
export const DocumentSigningFieldsUninserted = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
<p className="text-foreground group-hover:text-recipient-green whitespace-pre-wrap text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@ -34,10 +34,10 @@ export const DocumentSigningFieldsInserted = ({
|
||||
textAlign = 'left',
|
||||
}: DocumentSigningFieldsInsertedProps) => {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center">
|
||||
<div className="flex h-full w-full items-center overflow-hidden">
|
||||
<p
|
||||
className={cn(
|
||||
'text-foreground w-full text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
'text-foreground w-full whitespace-pre-wrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
{
|
||||
'!text-center': textAlign === 'center',
|
||||
'!text-right': textAlign === 'right',
|
||||
|
||||
@ -16,7 +16,6 @@ import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/uti
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
@ -177,15 +176,7 @@ export const DocumentSigningForm = ({
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
|
||||
{
|
||||
'top-20 max-h-[min(68rem,calc(100vh-6rem))]': user,
|
||||
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !user,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
@ -194,21 +185,8 @@ export const DocumentSigningForm = ({
|
||||
|
||||
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
|
||||
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
|
||||
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
|
||||
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
|
||||
</h3>
|
||||
|
||||
{recipient.role === RecipientRole.VIEWER ? (
|
||||
<>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Please mark as viewed to complete</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4" />
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
@ -245,15 +223,6 @@ export const DocumentSigningForm = ({
|
||||
) : recipient.role === RecipientRole.ASSISTANT ? (
|
||||
<>
|
||||
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>
|
||||
Complete the fields for the following signers. Once reviewed, they will inform
|
||||
you if any modifications are needed.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border my-4" />
|
||||
|
||||
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
|
||||
<Controller
|
||||
name="selectedSignerId"
|
||||
@ -340,88 +309,76 @@ export const DocumentSigningForm = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
||||
<Trans>Please review the document before approving.</Trans>
|
||||
) : (
|
||||
<Trans>Please review the document before signing.</Trans>
|
||||
)}
|
||||
</p>
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
{hasSignatureField && (
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasSignatureField && (
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={async () => navigate(-1)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
role={recipient.role}
|
||||
allowDictateNextSigner={
|
||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||
}
|
||||
defaultNextSigner={
|
||||
nextRecipient
|
||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={async () => navigate(-1)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
role={recipient.role}
|
||||
allowDictateNextSigner={
|
||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||
}
|
||||
defaultNextSigner={
|
||||
nextRecipient
|
||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -54,7 +54,7 @@ export const DocumentSigningNumberField = ({
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
const [showNumberModal, setShowNumberModal] = useState(false);
|
||||
|
||||
@ -62,8 +62,8 @@ export const DocumentSigningNumberField = ({
|
||||
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
||||
|
||||
const defaultValue = parsedFieldMeta?.value;
|
||||
const [localNumber, setLocalNumber] = useState(
|
||||
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
|
||||
const [localNumber, setLocalNumber] = useState(() =>
|
||||
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '',
|
||||
);
|
||||
|
||||
const initialErrors: ValidationErrors = {
|
||||
@ -213,16 +213,13 @@ export const DocumentSigningNumberField = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNumberModal) {
|
||||
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
|
||||
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '');
|
||||
setErrors(initialErrors);
|
||||
}
|
||||
}, [showNumberModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(!field.inserted && defaultValue && localNumber) ||
|
||||
(!field.inserted && parsedFieldMeta?.readOnly && defaultValue)
|
||||
) {
|
||||
if (!field.inserted && defaultValue) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
@ -318,7 +315,7 @@ export const DocumentSigningNumberField = ({
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowNumberModal(false);
|
||||
setLocalNumber('');
|
||||
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '');
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
|
||||
@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
@ -20,6 +21,7 @@ import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
@ -62,6 +64,7 @@ export const DocumentSigningPageView = ({
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
let senderName = document.user.name ?? '';
|
||||
let senderEmail = `(${document.user.email})`;
|
||||
@ -72,18 +75,20 @@ export const DocumentSigningPageView = ({
|
||||
}
|
||||
|
||||
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
|
||||
const targetSigner =
|
||||
recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null;
|
||||
|
||||
return (
|
||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
||||
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-y-2 sm:mt-2.5 sm:gap-y-0">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="text-muted-foreground truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
@ -133,26 +138,79 @@ export const DocumentSigningPageView = ({
|
||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||
<Card
|
||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
||||
<div className="flex-1">
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||
<DocumentSigningForm
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
setSelectedSignerId={setSelectedSignerId}
|
||||
/>
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="group/document-widget fixed bottom-6 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-4 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.otherwise(() => null)}
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<Trans>Please mark as viewed to complete.</Trans>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<Trans>Please review the document before signing.</Trans>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<Trans>Please review the document before approving.</Trans>
|
||||
))
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<Trans>Complete the fields for the following signers.</Trans>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
</div>
|
||||
|
||||
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||
<DocumentSigningForm
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
setSelectedSignerId={setSelectedSignerId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@ export const DocumentSigningRadioField = ({
|
||||
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
const parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
const isReadOnly = parsedFieldMeta.readOnly;
|
||||
const values = parsedFieldMeta.values?.map((item) => ({
|
||||
...item,
|
||||
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
@ -164,6 +165,7 @@ export const DocumentSigningRadioField = ({
|
||||
value={item.value}
|
||||
id={`option-${field.id}-${item.id}`}
|
||||
checked={item.checked}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{!item.value.includes('empty-value-') && item.value && (
|
||||
<Label
|
||||
@ -187,6 +189,7 @@ export const DocumentSigningRadioField = ({
|
||||
value={item.value}
|
||||
id={`option-${field.id}-${item.id}`}
|
||||
checked={item.value === field.customText}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{!item.value.includes('empty-value-') && item.value && (
|
||||
<Label
|
||||
|
||||
@ -38,11 +38,6 @@ export const DocumentSigningRecipientProvider = ({
|
||||
recipient,
|
||||
targetSigner = null,
|
||||
}: DocumentSigningRecipientProviderProps) => {
|
||||
// console.log({
|
||||
// recipient,
|
||||
// targetSigner,
|
||||
// isAssistantMode: !!targetSigner,
|
||||
// });
|
||||
return (
|
||||
<DocumentSigningRecipientContext.Provider
|
||||
value={{
|
||||
|
||||
@ -227,19 +227,8 @@ export const DocumentSigningTextField = ({
|
||||
|
||||
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
|
||||
|
||||
const labelDisplay =
|
||||
parsedField?.label && parsedField.label.length < 20
|
||||
? parsedField.label
|
||||
: parsedField?.label
|
||||
? parsedField?.label.substring(0, 20) + '...'
|
||||
: undefined;
|
||||
|
||||
const textDisplay =
|
||||
parsedField?.text && parsedField.text.length < 20
|
||||
? parsedField.text
|
||||
: parsedField?.text
|
||||
? parsedField?.text.substring(0, 20) + '...'
|
||||
: undefined;
|
||||
const labelDisplay = parsedField?.label;
|
||||
const textDisplay = parsedField?.text;
|
||||
|
||||
const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay;
|
||||
const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);
|
||||
@ -262,9 +251,7 @@ export const DocumentSigningTextField = ({
|
||||
|
||||
{field.inserted && (
|
||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||
{field.customText.length < 20
|
||||
? field.customText
|
||||
: field.customText.substring(0, 20) + '...'}
|
||||
{field.customText}
|
||||
</DocumentSigningFieldsInserted>
|
||||
)}
|
||||
|
||||
|
||||
@ -67,7 +67,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone,
|
||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
||||
@ -12,6 +13,7 @@ import {
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import type { TDocument } from '@documenso/lib/types/document';
|
||||
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -175,13 +177,17 @@ export const DocumentEditForm = ({
|
||||
try {
|
||||
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.safeParse(data.globalAccessAuth);
|
||||
|
||||
await updateDocument({
|
||||
documentId: document.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: data.globalAccessAuth ?? [],
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
@ -272,7 +278,8 @@ export const DocumentEditForm = ({
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message, distributionMethod, emailSettings } = data.meta;
|
||||
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
|
||||
data.meta;
|
||||
|
||||
try {
|
||||
await sendDocument({
|
||||
@ -281,7 +288,9 @@ export const DocumentEditForm = ({
|
||||
subject,
|
||||
message,
|
||||
distributionMethod,
|
||||
emailSettings,
|
||||
emailId,
|
||||
emailReplyTo: emailReplyTo || null,
|
||||
emailSettings: emailSettings,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
|
||||
export type DocumentHistorySheetChangesProps = {
|
||||
values: {
|
||||
key: string | React.ReactNode;
|
||||
value: string | React.ReactNode;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const DocumentHistorySheetChanges = ({ values }: DocumentHistorySheetChangesProps) => {
|
||||
return (
|
||||
<Badge
|
||||
className="text-muted-foreground mt-3 block w-full space-y-0.5 text-xs"
|
||||
variant="neutral"
|
||||
>
|
||||
{values.map(({ key, value }, i) => (
|
||||
<p key={typeof key === 'string' ? key : i}>
|
||||
<span>{key}: </span>
|
||||
<span className="font-normal">{value}</span>
|
||||
</p>
|
||||
))}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@ -1,410 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ArrowRightIcon, Loader } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
||||
|
||||
import { DocumentHistorySheetChanges } from './document-history-sheet-changes';
|
||||
|
||||
export type DocumentHistorySheetProps = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
isMenuOpen?: boolean;
|
||||
onMenuOpenChange?: (_value: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DocumentHistorySheet = ({
|
||||
documentId,
|
||||
userId,
|
||||
isMenuOpen,
|
||||
onMenuOpenChange,
|
||||
children,
|
||||
}: DocumentHistorySheetProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isLoadingError,
|
||||
refetch,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
|
||||
{
|
||||
documentId,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
|
||||
|
||||
const extractBrowser = (userAgent?: string | null) => {
|
||||
if (!userAgent) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const parser = new UAParser(userAgent);
|
||||
|
||||
parser.setUA(userAgent);
|
||||
|
||||
const result = parser.getResult();
|
||||
|
||||
return result.browser.name;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the following formatting for a given text:
|
||||
* - Uppercase first lower, lowercase rest
|
||||
* - Replace _ with spaces
|
||||
*
|
||||
* @param text The text to format
|
||||
* @returns The formatted text
|
||||
*/
|
||||
const formatGenericText = (text?: string | string[] | null): string => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (Array.isArray(text)) {
|
||||
return text.map((t) => formatGenericText(t)).join(', ');
|
||||
}
|
||||
|
||||
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
{children && <SheetTrigger asChild>{children}</SheetTrigger>}
|
||||
|
||||
<SheetContent
|
||||
sheetClass="backdrop-blur-none"
|
||||
className="flex w-full max-w-[500px] flex-col overflow-y-auto p-0"
|
||||
>
|
||||
<div className="text-foreground px-6 pt-6">
|
||||
<h1 className="text-lg font-medium">
|
||||
<Trans>Document history</Trans>
|
||||
</h1>
|
||||
<button
|
||||
className="text-muted-foreground text-sm"
|
||||
onClick={() => setIsUserDetailsVisible(!isUserDetailsVisible)}
|
||||
>
|
||||
{isUserDetailsVisible ? (
|
||||
<Trans>Hide additional information</Trans>
|
||||
) : (
|
||||
<Trans>Show additional information</Trans>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoadingError && (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<p className="text-foreground/80 text-sm">
|
||||
<Trans>Unable to load document history</Trans>
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => refetch()}
|
||||
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
||||
>
|
||||
<Trans>Click here to retry</Trans>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<ul
|
||||
className={cn('divide-y border-t', {
|
||||
'mb-4 border-b': !hasNextPage,
|
||||
})}
|
||||
>
|
||||
{documentAuditLogs.map((auditLog) => (
|
||||
<li className="px-4 py-2.5" key={auditLog.id}>
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="mr-2 h-9 w-9">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<p className="text-foreground text-xs font-bold">
|
||||
{formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||
</p>
|
||||
<p className="text-foreground/50 text-xs">
|
||||
{DateTime.fromJSDate(auditLog.createdAt)
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toFormat('d MMM, yyyy HH:MM a')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{match(auditLog)
|
||||
.with(
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM },
|
||||
() => null,
|
||||
)
|
||||
.with(
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED },
|
||||
({ data }) => {
|
||||
const values = [
|
||||
{
|
||||
key: 'Email',
|
||||
value: data.recipientEmail,
|
||||
},
|
||||
{
|
||||
key: 'Role',
|
||||
value: formatGenericText(data.recipientRole),
|
||||
},
|
||||
];
|
||||
|
||||
// Insert the name to the start of the array if available.
|
||||
if (data.recipientName) {
|
||||
values.unshift({
|
||||
key: 'Name',
|
||||
value: data.recipientName,
|
||||
});
|
||||
}
|
||||
|
||||
return <DocumentHistorySheetChanges values={values} />;
|
||||
},
|
||||
)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => {
|
||||
if (data.changes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentHistorySheetChanges
|
||||
values={data.changes.map(({ type, from, to }) => ({
|
||||
key: formatGenericText(type),
|
||||
value: (
|
||||
<span className="inline-flex flex-row items-center">
|
||||
<span>{type === 'ROLE' ? formatGenericText(from) : from}</span>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
<span>{type === 'ROLE' ? formatGenericText(to) : to}</span>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with(
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED },
|
||||
({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Field',
|
||||
value: formatGenericText(data.fieldType),
|
||||
},
|
||||
{
|
||||
key: 'Recipient',
|
||||
value: formatGenericText(data.fieldRecipientEmail),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with(
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED },
|
||||
({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Old',
|
||||
value: Array.isArray(data.from)
|
||||
? data.from
|
||||
.map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None')
|
||||
.join(', ')
|
||||
: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
|
||||
},
|
||||
{
|
||||
key: 'New',
|
||||
value: Array.isArray(data.to)
|
||||
? data.to
|
||||
.map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None')
|
||||
.join(', ')
|
||||
: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
|
||||
if (data.changes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentHistorySheetChanges
|
||||
values={data.changes.map((change) => ({
|
||||
key: formatGenericText(change.type),
|
||||
value: change.type === 'PASSWORD' ? '*********' : change.to,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Old',
|
||||
value: data.from,
|
||||
},
|
||||
{
|
||||
key: 'New',
|
||||
value: data.to,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.with(
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED },
|
||||
({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Old',
|
||||
value: data.from,
|
||||
},
|
||||
{
|
||||
key: 'New',
|
||||
value: data.to,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Field inserted',
|
||||
value: formatGenericText(data.field.type),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Field uninserted',
|
||||
value: formatGenericText(data.field),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Type',
|
||||
value: DOCUMENT_AUDIT_LOG_EMAIL_FORMAT[data.emailType].description,
|
||||
},
|
||||
{
|
||||
key: 'Sent to',
|
||||
value: data.recipientEmail,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.with(
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED },
|
||||
({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Old',
|
||||
value: data.from,
|
||||
},
|
||||
{
|
||||
key: 'New',
|
||||
value: data.to,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Field prefilled',
|
||||
value: formatGenericText(data.field.type),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.exhaustive()}
|
||||
|
||||
{isUserDetailsVisible && (
|
||||
<>
|
||||
<div className="mb-1 mt-2 flex flex-row space-x-2">
|
||||
<Badge variant="neutral" className="text-muted-foreground">
|
||||
IP: {auditLog.ipAddress ?? 'Unknown'}
|
||||
</Badge>
|
||||
|
||||
<Badge variant="neutral" className="text-muted-foreground">
|
||||
Browser: {extractBrowser(auditLog.userAgent)}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={async () => fetchNextPage()}
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@ -164,7 +164,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`${documentsPath}/${document.id}/logs`}>
|
||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Audit Log</Trans>
|
||||
<Trans>Audit Logs</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
|
||||
@ -30,8 +30,12 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSearch(searchTerm);
|
||||
}, [debouncedSearchTerm]);
|
||||
const currentQueryParam = searchParams.get('query') || '';
|
||||
|
||||
if (debouncedSearchTerm !== currentQueryParam) {
|
||||
handleSearch(debouncedSearchTerm);
|
||||
}
|
||||
}, [debouncedSearchTerm, searchParams]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
||||
@ -78,7 +78,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone,
|
||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@ -1,19 +1,32 @@
|
||||
import { FolderIcon, PinIcon } from 'lucide-react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { FolderType } from '@prisma/client';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
FolderIcon,
|
||||
FolderPlusIcon,
|
||||
MoreVerticalIcon,
|
||||
PinIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatFolderCount } from '@documenso/lib/utils/format-folder-count';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type FolderCardProps = {
|
||||
folder: TFolderWithSubfolders;
|
||||
onNavigate: (folderId: string) => void;
|
||||
onMove: (folder: TFolderWithSubfolders) => void;
|
||||
onPin: (folderId: string) => void;
|
||||
onUnpin: (folderId: string) => void;
|
||||
@ -23,66 +36,132 @@ export type FolderCardProps = {
|
||||
|
||||
export const FolderCard = ({
|
||||
folder,
|
||||
onNavigate,
|
||||
onMove,
|
||||
onPin,
|
||||
onUnpin,
|
||||
onSettings,
|
||||
onDelete,
|
||||
}: FolderCardProps) => {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const formatPath = () => {
|
||||
const rootPath =
|
||||
folder.type === FolderType.DOCUMENT
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
return `${rootPath}/f/${folder.id}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={folder.id}
|
||||
className="border-border hover:border-muted-foreground/40 group relative flex flex-col rounded-lg border p-4 transition-all hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<button
|
||||
className="flex items-center space-x-2 text-left"
|
||||
onClick={() => onNavigate(folder.id)}
|
||||
>
|
||||
<FolderIcon className="text-documenso h-6 w-6" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{folder.name}</h3>
|
||||
{folder.pinned && <PinIcon className="text-documenso h-3 w-3" />}
|
||||
</div>
|
||||
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
|
||||
<span>
|
||||
{formatFolderCount(
|
||||
folder.type === FolderType.TEMPLATE
|
||||
? folder._count.templates
|
||||
: folder._count.documents,
|
||||
folder.type === FolderType.TEMPLATE ? 'template' : 'document',
|
||||
folder.type === FolderType.TEMPLATE ? 'templates' : 'documents',
|
||||
)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatFolderCount(folder._count.subfolders, 'folder', 'folders')}</span>
|
||||
<Link to={formatPath()} key={folder.id}>
|
||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<FolderIcon className="text-documenso h-6 w-6 flex-shrink-0" />
|
||||
|
||||
<div className="flex w-full min-w-0 items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="flex min-w-0 items-center gap-2 font-medium">
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{folder.pinned && <PinIcon className="text-documenso h-3 w-3 flex-shrink-0" />}
|
||||
</h3>
|
||||
|
||||
<div className="text-muted-foreground mt-1 flex space-x-2 truncate text-xs">
|
||||
<span>
|
||||
{folder.type === FolderType.TEMPLATE ? (
|
||||
<Plural
|
||||
value={folder._count.templates}
|
||||
one={<Trans># template</Trans>}
|
||||
other={<Trans># templates</Trans>}
|
||||
/>
|
||||
) : (
|
||||
<Plural
|
||||
value={folder._count.documents}
|
||||
one={<Trans># document</Trans>}
|
||||
other={<Trans># documents</Trans>}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
<Plural
|
||||
value={folder._count.subfolders}
|
||||
one={<Trans># folder</Trans>}
|
||||
other={<Trans># folders</Trans>}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
data-testid="folder-card-more-button"
|
||||
>
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent onClick={(e) => e.stopPropagation()} align="end">
|
||||
<DropdownMenuItem onClick={() => onMove(folder)}>
|
||||
<ArrowRightIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
|
||||
>
|
||||
<PinIcon className="mr-2 h-4 w-4" />
|
||||
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => onSettings(folder)}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Settings</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={() => onDelete(folder)}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100">
|
||||
•••
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onMove(folder)}>Move</DropdownMenuItem>
|
||||
{folder.pinned ? (
|
||||
<DropdownMenuItem onClick={() => onUnpin(folder.id)}>Unpin</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onPin(folder.id)}>Pin</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onSettings(folder)}>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-500" onClick={() => onDelete(folder)}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const FolderCardEmpty = ({ type }: { type: FolderType }) => {
|
||||
return (
|
||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderPlusIcon className="text-muted-foreground/60 h-6 w-6" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-muted-foreground flex items-center gap-2 font-medium">
|
||||
<Trans>Create folder</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="text-muted-foreground/60 mt-1 flex space-x-2 truncate text-xs">
|
||||
{type === FolderType.DOCUMENT ? (
|
||||
<Trans>Organise your documents</Trans>
|
||||
) : (
|
||||
<Trans>Organise your templates</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
249
apps/remix/app/components/general/folder/folder-grid.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FolderType } from '@prisma/client';
|
||||
import { FolderIcon, HomeIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
|
||||
import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
|
||||
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
||||
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
||||
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type FolderGridProps = {
|
||||
type: FolderType;
|
||||
parentId: string | null;
|
||||
};
|
||||
|
||||
export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
|
||||
type,
|
||||
parentId,
|
||||
});
|
||||
|
||||
const formatBreadCrumbPath = (folderId: string) => {
|
||||
const rootPath =
|
||||
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
|
||||
return `${rootPath}/f/${folderId}`;
|
||||
};
|
||||
|
||||
const formatViewAllFoldersPath = () => {
|
||||
const rootPath =
|
||||
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
|
||||
return `${rootPath}/folders`;
|
||||
};
|
||||
|
||||
const formatRootPath = () => {
|
||||
return type === FolderType.DOCUMENT
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
};
|
||||
|
||||
const pinnedFolders = foldersData?.folders.filter((folder) => folder.pinned) || [];
|
||||
const unpinnedFolders = foldersData?.folders.filter((folder) => !folder.pinned) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 flex flex-1 items-center text-sm font-medium"
|
||||
data-testid="folder-grid-breadcrumbs"
|
||||
>
|
||||
<Link to={formatRootPath()} className="flex items-center">
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Home</Trans>
|
||||
</Link>
|
||||
|
||||
{isPending && parentId ? (
|
||||
<div className="flex items-center">
|
||||
<Skeleton className="mx-3 h-4 w-1 rotate-12" />
|
||||
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
) : (
|
||||
foldersData?.breadcrumbs.map((folder) => (
|
||||
<div key={folder.id} className="flex items-center">
|
||||
<span className="px-3">/</span>
|
||||
<Link to={formatBreadCrumbPath(folder.id)} className="flex items-center">
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
<span>{folder.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||
{type === FolderType.DOCUMENT ? (
|
||||
<DocumentUploadDropzone />
|
||||
) : (
|
||||
<TemplateCreateDialog folderId={parentId ?? undefined} />
|
||||
)}
|
||||
|
||||
<FolderCreateDialog type={type} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="border-border bg-card h-full rounded-lg border px-4 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<Skeleton className="mb-2 h-4 w-24" />
|
||||
<div className="flex space-x-2">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-3" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : foldersData && foldersData.folders.length === 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<FolderCreateDialog
|
||||
type={type}
|
||||
trigger={
|
||||
<button>
|
||||
<FolderCardEmpty type={type} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
foldersData && (
|
||||
<div key="content" className="space-y-4">
|
||||
{pinnedFolders.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{pinnedFolders.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unpinnedFolders.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{unpinnedFolders.slice(0, 12).map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{foldersData.folders.length > 12 && (
|
||||
<div className="mt-2 flex items-center justify-center">
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium"
|
||||
to={formatViewAllFoldersPath()}
|
||||
>
|
||||
View all folders
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<FolderMoveDialog
|
||||
foldersData={foldersData?.folders}
|
||||
folder={folderToMove}
|
||||
isOpen={isMovingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsMovingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToMove(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderUpdateDialog
|
||||
folder={folderToSettings}
|
||||
isOpen={isSettingsFolderOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsSettingsFolderOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToSettings(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{folderToDelete && (
|
||||
<FolderDeleteDialog
|
||||
folder={folderToDelete}
|
||||
isOpen={isDeletingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeletingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToDelete(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -321,6 +321,19 @@ export const OrgMenuSwitcher = () => {
|
||||
<Trans>Language</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{currentOrganisation && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link
|
||||
to={{
|
||||
pathname: `/o/${currentOrganisation.url}/support`,
|
||||
search: currentTeam ? `?team=${currentTeam.id}` : '',
|
||||
}}
|
||||
>
|
||||
<Trans>Support</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
|
||||
onSelect={async () => authClient.signOut()}
|
||||
|
||||
@ -5,18 +5,23 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@ -30,7 +35,7 @@ export const OrganisationBillingBanner = () => {
|
||||
const organisation = useOptionalCurrentOrganisation();
|
||||
|
||||
const { mutateAsync: manageSubscription, isPending } =
|
||||
trpc.billing.subscription.manage.useMutation();
|
||||
trpc.enterprise.billing.subscription.manage.useMutation();
|
||||
|
||||
const handleCreatePortal = async (organisationId: string) => {
|
||||
try {
|
||||
@ -86,7 +91,7 @@ export const OrganisationBillingBanner = () => {
|
||||
className={cn({
|
||||
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
|
||||
subscriptionStatus === SubscriptionStatus.PAST_DUE,
|
||||
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
|
||||
'text-destructive-foreground hover:bg-destructive hover:text-white':
|
||||
subscriptionStatus === SubscriptionStatus.INACTIVE,
|
||||
})}
|
||||
disabled={isPending}
|
||||
@ -99,38 +104,78 @@ export const OrganisationBillingBanner = () => {
|
||||
</div>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
<Trans>Payment overdue</Trans>
|
||||
</DialogTitle>
|
||||
{match(subscriptionStatus)
|
||||
.with(SubscriptionStatus.PAST_DUE, () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Payment overdue</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
{match(subscriptionStatus)
|
||||
.with(SubscriptionStatus.PAST_DUE, () => (
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Your payment for teams is overdue. Please settle the payment to avoid any service
|
||||
disruptions.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
))
|
||||
.with(SubscriptionStatus.INACTIVE, () => (
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Due to an unpaid invoice, your team has been restricted. Please settle the payment
|
||||
to restore full access to your team.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Your payment is overdue. Please settle the payment to avoid any service
|
||||
disruptions.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||
<DialogFooter>
|
||||
<Button loading={isPending} onClick={async () => handleCreatePortal(organisation.id)}>
|
||||
<Trans>Resolve payment</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
{canExecuteOrganisationAction(
|
||||
'MANAGE_BILLING',
|
||||
organisation.currentOrganisationRole,
|
||||
) && (
|
||||
<DialogFooter>
|
||||
<Button
|
||||
loading={isPending}
|
||||
onClick={async () => handleCreatePortal(organisation.id)}
|
||||
>
|
||||
<Trans>Resolve payment</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with(SubscriptionStatus.INACTIVE, () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Subscription invalid</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Your plan is no longer valid. Please subscribe to a new plan to continue using
|
||||
Documenso.
|
||||
</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>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -21,7 +21,7 @@ export const OrganisationBillingPortalButton = ({
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: manageSubscription, isPending } =
|
||||
trpc.billing.subscription.manage.useMutation();
|
||||
trpc.enterprise.billing.subscription.manage.useMutation();
|
||||
|
||||
const canManageBilling = canExecuteOrganisationAction(
|
||||
'MANAGE_BILLING',
|
||||
|
||||
@ -46,16 +46,46 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
||||
|
||||
{isPersonalLayoutMode && (
|
||||
<>
|
||||
<Link to="/settings/preferences">
|
||||
<Link to="/settings/document">
|
||||
<Button variant="ghost" className={cn('w-full justify-start')}>
|
||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link className="w-full pl-8" to="/settings/document">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/preferences') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/document') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
<Trans>Document</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link className="w-full pl-8" to="/settings/branding">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/branding') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Trans>Branding</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link className="w-full pl-8" to="/settings/email">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/email') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Trans>Email</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@ import {
|
||||
CreditCardIcon,
|
||||
Globe2Icon,
|
||||
Lock,
|
||||
MailIcon,
|
||||
PaletteIcon,
|
||||
Settings2Icon,
|
||||
User,
|
||||
Users,
|
||||
@ -48,16 +50,42 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
||||
|
||||
{isPersonalLayoutMode && (
|
||||
<>
|
||||
<Link to="/settings/preferences">
|
||||
<Link to="/settings/document">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/preferences') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/document') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
<Trans>Document Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/branding">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/branding') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<PaletteIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Branding Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/email">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/email') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<MailIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Email Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
|
||||
@ -28,10 +28,6 @@ export const ShareDocumentDownloadButton = ({
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 4000);
|
||||
});
|
||||
|
||||
await downloadPDF({ documentData, fileName: title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Braces, Globe2Icon, GroupIcon, Settings, Settings2, Users, Webhook } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type TeamSettingsNavDesktopProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavDesktopProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const params = useParams();
|
||||
|
||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||
|
||||
const settingsPath = `/t/${teamUrl}/settings`;
|
||||
const preferencesPath = `/t/${teamUrl}/settings/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const groupsPath = `/t/${teamUrl}/settings/groups`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
<Link to={settingsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname === settingsPath && 'bg-secondary')}
|
||||
>
|
||||
<Settings className="mr-2 h-5 w-5" />
|
||||
<Trans>General</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={preferencesPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(preferencesPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={publicProfilePath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={membersPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
<Trans>Members</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={groupsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(groupsPath) && 'bg-secondary')}
|
||||
>
|
||||
<GroupIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Groups</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={tokensPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
|
||||
>
|
||||
<Braces className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={webhooksPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(webhooksPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,121 +0,0 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Braces, Globe2Icon, GroupIcon, Key, Settings2, User, Webhook } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type TeamSettingsNavMobileProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TeamSettingsNavMobile = ({ className, ...props }: TeamSettingsNavMobileProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const params = useParams();
|
||||
|
||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||
|
||||
const settingsPath = `/t/${teamUrl}/settings`;
|
||||
const preferencesPath = `/t/${teamUrl}/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const groupsPath = `/t/${teamUrl}/settings/groups`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<Link to={settingsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(settingsPath) &&
|
||||
pathname.split('/').length === 4 &&
|
||||
'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<User className="mr-2 h-5 w-5" />
|
||||
<Trans>General</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={preferencesPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(preferencesPath) &&
|
||||
pathname.split('/').length === 4 &&
|
||||
'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2 className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={publicProfilePath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={membersPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Key className="mr-2 h-5 w-5" />
|
||||
<Trans>Members</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={groupsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(groupsPath) && 'bg-secondary')}
|
||||
>
|
||||
<GroupIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Groups</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={tokensPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
|
||||
>
|
||||
<Braces className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={webhooksPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(webhooksPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,129 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export interface TemplateDropZoneWrapperProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZoneWrapperProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const documentData = await putPdfFile(file);
|
||||
|
||||
const { id } = await createTemplate({
|
||||
title: file.name,
|
||||
templateDocumentDataId: documentData.id,
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Template uploaded`),
|
||||
description: _(
|
||||
msg`Your template has been uploaded successfully. You will be redirected to the template page.`,
|
||||
),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`Please try again later.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
toast({
|
||||
title: _(msg`Your template failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
},
|
||||
//disabled: isUploadDisabled,
|
||||
multiple: false,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
onDrop: ([acceptedFile]) => {
|
||||
if (acceptedFile) {
|
||||
void onFileDrop(acceptedFile);
|
||||
}
|
||||
},
|
||||
onDropRejected: () => {
|
||||
void onFileDropRejected();
|
||||
},
|
||||
noClick: true,
|
||||
noDragEventsBubbling: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
|
||||
{isDragActive && (
|
||||
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||
<h2 className="text-foreground text-2xl font-semibold">
|
||||
<Trans>Upload Template</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-md mt-4">
|
||||
<Trans>Drag and drop your PDF file here</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
|
||||
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||
<p className="text-foreground mt-8 font-medium">
|
||||
<Trans>Uploading template...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
||||
@ -10,6 +11,7 @@ import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -125,6 +127,10 @@ export const TemplateEditForm = ({
|
||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
const { signatureTypes } = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.safeParse(data.globalAccessAuth);
|
||||
|
||||
try {
|
||||
await updateTemplateSettings({
|
||||
templateId: template.id,
|
||||
@ -132,11 +138,12 @@ export const TemplateEditForm = ({
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: data.globalAccessAuth ?? [],
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
...data.meta,
|
||||
emailReplyTo: data.meta.emailReplyTo || null,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
|
||||
@ -6,6 +6,8 @@ import { PenIcon, PlusIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
export type TemplatePageViewRecipientsProps = {
|
||||
@ -53,8 +55,18 @@ export const TemplatePageViewRecipients = ({
|
||||
{recipients.map((recipient) => (
|
||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
avatarFallback={
|
||||
isTemplateRecipientEmailPlaceholder(recipient.email)
|
||||
? extractInitials(recipient.name)
|
||||
: recipient.email.slice(0, 1).toUpperCase()
|
||||
}
|
||||
primaryText={
|
||||
isTemplateRecipientEmailPlaceholder(recipient.email) ? (
|
||||
<p className="text-muted-foreground text-sm">{recipient.name}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
)
|
||||
}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
|
||||
@ -188,7 +188,7 @@ export const DocumentsTableActionDropdown = ({
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onMoveDocument && (
|
||||
{onMoveDocument && canManageDocument && (
|
||||
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
|
||||
<FolderInput className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
|
||||
@ -1,20 +1,18 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
DOCUMENT_AUDIT_LOG_TYPE,
|
||||
type TDocumentAuditLog,
|
||||
} from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
export type AuditLogDataTableProps = {
|
||||
logs: TDocumentAuditLog[];
|
||||
@ -25,71 +23,129 @@ const dateFormat: DateTimeFormatOptions = {
|
||||
hourCycle: 'h12',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the color indicator for the audit log type
|
||||
*/
|
||||
|
||||
const getAuditLogIndicatorColor = (type: string) =>
|
||||
match(type)
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => 'bg-green-500')
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => 'bg-red-500')
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => 'bg-orange-500')
|
||||
.with(
|
||||
P.union(
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||
),
|
||||
() => 'bg-blue-500',
|
||||
)
|
||||
.otherwise(() => 'bg-muted');
|
||||
|
||||
/**
|
||||
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
|
||||
*/
|
||||
|
||||
const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UAParser.IResult) => {
|
||||
if (!userAgent) {
|
||||
return msg`N/A`;
|
||||
}
|
||||
|
||||
const browser = userAgentInfo.browser.name;
|
||||
const version = userAgentInfo.browser.version;
|
||||
const os = userAgentInfo.os.name;
|
||||
|
||||
// If we can parse meaningful browser info, format it nicely
|
||||
if (browser && os) {
|
||||
const browserInfo = version ? `${browser} ${version}` : browser;
|
||||
|
||||
return msg`${browserInfo} on ${os}`;
|
||||
}
|
||||
|
||||
return msg`${userAgent}`;
|
||||
};
|
||||
|
||||
export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const parser = new UAParser();
|
||||
|
||||
const uppercaseFistLetter = (text: string) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_(msg`Time`)}</TableHead>
|
||||
<TableHead>{_(msg`User`)}</TableHead>
|
||||
<TableHead>{_(msg`Action`)}</TableHead>
|
||||
<TableHead>{_(msg`IP Address`)}</TableHead>
|
||||
<TableHead>{_(msg`Browser`)}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<div className="space-y-4">
|
||||
{logs.map((log, index) => {
|
||||
parser.setUA(log.userAgent || '');
|
||||
const formattedAction = formatDocumentAuditLogAction(_, log);
|
||||
const userAgentInfo = parser.getResult();
|
||||
|
||||
<TableBody className="print:text-xs">
|
||||
{logs.map((log, i) => (
|
||||
<TableRow className="break-inside-avoid" key={i}>
|
||||
<TableCell>
|
||||
{DateTime.fromJSDate(log.createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toLocaleString(dateFormat)}
|
||||
</TableCell>
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
// Add top margin for the first card to ensure it's not cut off from the 2nd page onwards
|
||||
className={`border shadow-sm ${index > 0 ? 'print:mt-8' : ''}`}
|
||||
style={{
|
||||
pageBreakInside: 'avoid',
|
||||
breakInside: 'avoid',
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
{/* Header Section with indicator, event type, and timestamp */}
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<div
|
||||
className={cn(`h-2 w-2 rounded-full`, getAuditLogIndicatorColor(log.type))}
|
||||
/>
|
||||
|
||||
<TableCell>
|
||||
{log.name || log.email ? (
|
||||
<div>
|
||||
{log.name && (
|
||||
<p className="break-all" title={log.name}>
|
||||
{log.name}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-muted-foreground text-sm font-medium uppercase tracking-wide print:text-[8pt]">
|
||||
{log.type.replace(/_/g, ' ')}
|
||||
</div>
|
||||
|
||||
{log.email && (
|
||||
<p className="text-muted-foreground break-all" title={log.email}>
|
||||
{log.email}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-foreground text-sm font-medium print:text-[8pt]">
|
||||
{formattedAction.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p>N/A</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)}
|
||||
</TableCell>
|
||||
<div className="text-muted-foreground text-sm print:text-[8pt]">
|
||||
{DateTime.fromJSDate(log.createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toLocaleString(dateFormat)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableCell>{log.ipAddress}</TableCell>
|
||||
<hr className="my-4" />
|
||||
|
||||
<TableCell>
|
||||
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/* Details Section - Two column layout */}
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-xs print:text-[6pt]">
|
||||
<div>
|
||||
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
|
||||
{_(msg`User`)}
|
||||
</div>
|
||||
|
||||
<div className="text-foreground mt-1 font-mono">{log.email || 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
|
||||
{_(msg`IP Address`)}
|
||||
</div>
|
||||
|
||||
<div className="text-foreground mt-1 font-mono">{log.ipAddress || 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
|
||||
{_(msg`User Agent`)}
|
||||
</div>
|
||||
|
||||
<div className="text-foreground mt-1">
|
||||
{_(formatUserAgent(log.userAgent, userAgentInfo))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -25,7 +25,7 @@ export const OrganisationBillingInvoicesTable = ({
|
||||
}: OrganisationBillingInvoicesTableProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.billing.invoices.get.useQuery(
|
||||
const { data, isLoading, isLoadingError } = trpc.enterprise.billing.invoices.get.useQuery(
|
||||
{
|
||||
organisationId,
|
||||
},
|
||||
|
||||
@ -0,0 +1,205 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EmailDomainStatus } from '@prisma/client';
|
||||
import { CheckCircle2Icon, ClockIcon } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { OrganisationEmailDomainDeleteDialog } from '../dialogs/organisation-email-domain-delete-dialog';
|
||||
|
||||
export const OrganisationEmailDomainsDataTable = () => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { mutate: verifyEmails, isPending: isVerifyingEmails } =
|
||||
trpc.enterprise.organisation.emailDomain.verify.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Email domains synced`,
|
||||
description: t`All email domains have been synced successfully`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isLoading, isLoadingError } =
|
||||
trpc.enterprise.organisation.emailDomain.find.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: t`Domain`,
|
||||
accessorKey: 'domain',
|
||||
},
|
||||
{
|
||||
header: t`Status`,
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) =>
|
||||
match(row.original.status)
|
||||
.with(EmailDomainStatus.ACTIVE, () => (
|
||||
<Badge>
|
||||
<CheckCircle2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
|
||||
<Trans>Active</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(EmailDomainStatus.PENDING, () => (
|
||||
<Badge variant="warning">
|
||||
<ClockIcon className="mr-2 h-4 w-4 text-yellow-500 dark:text-yellow-200" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.exhaustive(),
|
||||
},
|
||||
{
|
||||
header: t`Emails`,
|
||||
accessorKey: 'emailCount',
|
||||
cell: ({ row }) => row.original.emailCount,
|
||||
},
|
||||
{
|
||||
header: t`Actions`,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/o/${organisation.url}/settings/email-domains/${row.original.id}`}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<OrganisationEmailDomainDeleteDialog
|
||||
emailDomainId={row.original.id}
|
||||
emailDomain={row.original.domain}
|
||||
trigger={
|
||||
<Button variant="destructive" title={t`Remove email domain`}>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 1,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-20 rounded" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-end space-x-2">
|
||||
<Skeleton className="h-10 w-20 rounded" />
|
||||
<Skeleton className="h-10 w-20 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) =>
|
||||
results.totalPages > 1 && (
|
||||
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||
)
|
||||
}
|
||||
</DataTable>
|
||||
|
||||
<AnimateGenericFadeInOut key={results.data.length}>
|
||||
{results.data.length > 0 && (
|
||||
<Alert
|
||||
className="mt-2 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Sync Email Domains</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
This will check and sync the status of all email domains for this organisation
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={isVerifyingEmails}
|
||||
onClick={() => {
|
||||
verifyEmails({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans>Sync</Trans>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -36,23 +36,7 @@ const { trackPageview } = Plausible({
|
||||
trackLocalhost: false,
|
||||
});
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{
|
||||
rel: 'preconnect',
|
||||
href: 'https://fonts.gstatic.com',
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400..600&display=swap',
|
||||
},
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
|
||||
},
|
||||
{ rel: 'stylesheet', href: stylesheet },
|
||||
];
|
||||
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags();
|
||||
|
||||
@ -18,11 +18,14 @@ import {
|
||||
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||
import {
|
||||
getMonthlyActiveUsers,
|
||||
getOrganisationsWithSubscriptionsCount,
|
||||
getUserWithSignedDocumentMonthlyGrowth,
|
||||
getUsersCount,
|
||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
|
||||
|
||||
import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts';
|
||||
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
|
||||
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
|
||||
import { CardMetric } from '~/components/general/metric-card';
|
||||
@ -37,18 +40,16 @@ export async function loader() {
|
||||
docStats,
|
||||
recipientStats,
|
||||
signerConversionMonthly,
|
||||
// userWithAtLeastOneDocumentPerMonth,
|
||||
// userWithAtLeastOneDocumentSignedPerMonth,
|
||||
// MONTHLY_USERS_SIGNED,
|
||||
monthlyUsersWithDocuments,
|
||||
monthlyActiveUsers,
|
||||
] = await Promise.all([
|
||||
getUsersCount(),
|
||||
getOrganisationsWithSubscriptionsCount(),
|
||||
getDocumentStats(),
|
||||
getRecipientsStats(),
|
||||
getSignerConversionMonthly(),
|
||||
// getUserWithAtLeastOneDocumentPerMonth(),
|
||||
// getUserWithAtLeastOneDocumentSignedPerMonth(),
|
||||
// getUserWithSignedDocumentMonthlyGrowth(),
|
||||
getUserWithSignedDocumentMonthlyGrowth(),
|
||||
getMonthlyActiveUsers(),
|
||||
]);
|
||||
|
||||
return {
|
||||
@ -57,7 +58,8 @@ export async function loader() {
|
||||
docStats,
|
||||
recipientStats,
|
||||
signerConversionMonthly,
|
||||
// MONTHLY_USERS_SIGNED,
|
||||
monthlyUsersWithDocuments,
|
||||
monthlyActiveUsers,
|
||||
};
|
||||
}
|
||||
|
||||
@ -70,7 +72,8 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
||||
docStats,
|
||||
recipientStats,
|
||||
signerConversionMonthly,
|
||||
// MONTHLY_USERS_SIGNED,
|
||||
monthlyUsersWithDocuments,
|
||||
monthlyActiveUsers,
|
||||
} = loaderData;
|
||||
|
||||
return (
|
||||
@ -147,15 +150,21 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
||||
<Trans>Charts</Trans>
|
||||
</h3>
|
||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||
<MonthlyActiveUsersChart title={_(msg`MAU (signed in)`)} data={monthlyActiveUsers} />
|
||||
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Cumulative MAU (signed in)`)}
|
||||
data={monthlyActiveUsers}
|
||||
cummulative
|
||||
/>
|
||||
|
||||
<AdminStatsUsersWithDocumentsChart
|
||||
data={[]}
|
||||
// data={MONTHLY_USERS_SIGNED}
|
||||
data={monthlyUsersWithDocuments}
|
||||
title={_(msg`MAU (created document)`)}
|
||||
tooltip={_(msg`Monthly Active Users: Users that created at least one Document`)}
|
||||
/>
|
||||
<AdminStatsUsersWithDocumentsChart
|
||||
data={[]}
|
||||
// data={MONTHLY_USERS_SIGNED}
|
||||
data={monthlyUsersWithDocuments}
|
||||
completed
|
||||
title={_(msg`MAU (had document completed)`)}
|
||||
tooltip={_(
|
||||
|
||||
@ -27,6 +27,7 @@ import { AdminOrganisationCreateDialog } from '~/components/dialogs/admin-organi
|
||||
import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog';
|
||||
import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog';
|
||||
import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-dialog';
|
||||
import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
|
||||
|
||||
@ -219,10 +220,11 @@ const AdminUserPage = ({ user }: { user: User }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex flex-col items-center gap-4">
|
||||
{user && <AdminUserDeleteDialog user={user} />}
|
||||
<div className="mt-16 flex flex-col gap-4">
|
||||
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
|
||||
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
|
||||
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
|
||||
{user && <AdminUserDeleteDialog user={user} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Building2Icon, CreditCardIcon, GroupIcon, Settings2Icon, Users2Icon } from 'lucide-react';
|
||||
import {
|
||||
Building2Icon,
|
||||
CreditCardIcon,
|
||||
GroupIcon,
|
||||
MailboxIcon,
|
||||
Settings2Icon,
|
||||
Users2Icon,
|
||||
} from 'lucide-react';
|
||||
import { FaUsers } from 'react-icons/fa6';
|
||||
import { Link, NavLink, Outlet } from 'react-router';
|
||||
|
||||
@ -30,9 +37,30 @@ export default function SettingsLayout() {
|
||||
icon: Building2Icon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/preferences`,
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: t`Preferences`,
|
||||
icon: Settings2Icon,
|
||||
hideHighlight: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: t`Document`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/branding`,
|
||||
label: t`Branding`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/email`,
|
||||
label: t`Email`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/email-domains`,
|
||||
label: t`Email Domains`,
|
||||
icon: MailboxIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/teams`,
|
||||
@ -54,7 +82,20 @@ export default function SettingsLayout() {
|
||||
label: t`Billing`,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
].filter((route) => (isBillingEnabled ? route : !route.path.includes('/billing')));
|
||||
].filter((route) => {
|
||||
if (!isBillingEnabled && route.path.includes('/billing')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!isBillingEnabled || !organisation.organisationClaim.flags.emailDomains) &&
|
||||
route.path.includes('/email-domains')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) {
|
||||
return (
|
||||
@ -93,12 +134,18 @@ export default function SettingsLayout() {
|
||||
)}
|
||||
>
|
||||
{organisationSettingRoutes.map((route) => (
|
||||
<NavLink to={route.path} className="group w-full justify-start" key={route.path}>
|
||||
<NavLink
|
||||
to={route.path}
|
||||
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
|
||||
key={route.path}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group-aria-[current]:bg-secondary w-full justify-start"
|
||||
className={cn('w-full justify-start', {
|
||||
'group-aria-[current]:bg-secondary': !route.hideHighlight,
|
||||
})}
|
||||
>
|
||||
<route.icon className="mr-2 h-5 w-5" />
|
||||
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
|
||||
<Trans>{route.label}</Trans>
|
||||
</Button>
|
||||
</NavLink>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { Loader } from 'lucide-react';
|
||||
import type Stripe from 'stripe';
|
||||
import { match } from 'ts-pattern';
|
||||
@ -23,7 +24,7 @@ export default function TeamsSettingBillingPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
|
||||
trpc.billing.subscription.get.useQuery({
|
||||
trpc.enterprise.billing.subscription.get.useQuery({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
@ -134,7 +135,9 @@ export default function TeamsSettingBillingPage() {
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
{!subscription && canManageBilling && <BillingPlans plans={plans} />}
|
||||
{(!subscription ||
|
||||
subscription.organisationSubscription.status === SubscriptionStatus.INACTIVE) &&
|
||||
canManageBilling && <BillingPlans plans={plans} />}
|
||||
|
||||
<section className="mt-6">
|
||||
<OrganisationBillingInvoicesTable
|
||||
|
||||