Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f17288af6 |
@@ -105,12 +105,6 @@ 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=
|
||||
@@ -133,8 +127,4 @@ E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
|
||||
# [[LOGGER]]
|
||||
# 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=
|
||||
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: Pull Request
|
||||
about: Submit changes to the project for review and inclusion
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!--- Describe the changes introduced by this pull request. -->
|
||||
|
||||
@@ -20,6 +20,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
|
||||
- uses: ./.github/actions/node-install
|
||||
|
||||
|
||||
@@ -50,10 +50,3 @@ yarn-error.log*
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# logs
|
||||
logs.json
|
||||
|
||||
# claude
|
||||
.claude
|
||||
CLAUDE.md
|
||||
@@ -49,6 +49,8 @@ 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,6 +1,5 @@
|
||||
{
|
||||
"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.com/api/v2-beta/`
|
||||
`https://stg-app.documenso.dev/api/v2-beta/`
|
||||
</Callout>
|
||||
|
||||
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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,18 +619,6 @@ 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,7 +11,6 @@
|
||||
"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 **Branding** tab under the **Preferences** section.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"sending-documents": "Sending Documents",
|
||||
"document-preferences": "Document Preferences",
|
||||
"document-visibility": "Document Visibility",
|
||||
"fields": "Document Fields",
|
||||
"email-preferences": "Email Preferences"
|
||||
"document-visibility": "Document Visibility"
|
||||
}
|
||||
@@ -19,14 +19,12 @@ 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 **Document** tab under the **Preferences** section.
|
||||
To access the preferences, navigate to either the organisation or teams settings page and click the **Preferences** tab.
|
||||
|
||||

|
||||
|
||||
- **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.
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,111 +0,0 @@
|
||||
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>
|
||||
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 98 KiB |
@@ -1,30 +1,19 @@
|
||||
@import '@documenso/ui/styles/theme.css';
|
||||
|
||||
/* Inter Variable Fonts */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/inter-variablefont_opsz,wght.ttf') format('truetype-variations');
|
||||
font-weight: 100 900;
|
||||
src: url('/public/fonts/inter-regular.ttf') format('ttf');
|
||||
/* font-weight: 400;
|
||||
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('/fonts/caveat-variablefont_wght.ttf') format('truetype-variations');
|
||||
font-weight: 400 600;
|
||||
src: url('/public/fonts/caveat.ttf') format('ttf');
|
||||
/* font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-display: swap; */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@@ -96,17 +96,16 @@ export const AdminOrganisationCreateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<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">
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
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,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -73,23 +73,20 @@ export const DocumentDeleteDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInputValue('');
|
||||
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
||||
}
|
||||
}, [open, status]);
|
||||
|
||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
setInputValue('');
|
||||
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
||||
}
|
||||
if (!isPending) {
|
||||
onOpenChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
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 { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
@@ -31,7 +31,6 @@ 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';
|
||||
@@ -62,8 +61,6 @@ export const DocumentMoveToFolderDialog = ({
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const form = useForm<TMoveDocumentFormSchema>({
|
||||
resolver: zodResolver(ZMoveDocumentFormSchema),
|
||||
defaultValues: {
|
||||
@@ -83,6 +80,14 @@ export const DocumentMoveToFolderDialog = ({
|
||||
|
||||
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId });
|
||||
}
|
||||
}, [open, currentFolderId, form]);
|
||||
|
||||
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
||||
try {
|
||||
await moveDocumentToFolder({
|
||||
@@ -118,16 +123,6 @@ 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.`),
|
||||
@@ -136,22 +131,8 @@ export const DocumentMoveToFolderDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId });
|
||||
}
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
const filteredFolders = folders?.data.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -163,18 +144,8 @@ 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="flex flex-col gap-y-4">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderId"
|
||||
@@ -183,9 +154,8 @@ export const DocumentMoveToFolderDialog = ({
|
||||
<FormLabel>
|
||||
<Trans>Folder</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{isFoldersLoading ? (
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -200,10 +170,10 @@ export const DocumentMoveToFolderDialog = ({
|
||||
disabled={currentFolderId === null}
|
||||
>
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Home (No Folder)</Trans>
|
||||
<Trans>Root (No Folder)</Trans>
|
||||
</Button>
|
||||
|
||||
{filteredFolders?.map((folder) => (
|
||||
{folders?.data.map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
@@ -216,12 +186,6 @@ 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,14 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { FolderType } from '@prisma/client';
|
||||
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 { useParams } from 'react-router';
|
||||
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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -31,22 +34,26 @@ 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 FolderCreateDialogProps = {
|
||||
type: FolderType;
|
||||
export type CreateFolderDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
|
||||
const { _ } = 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();
|
||||
@@ -60,91 +67,94 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
|
||||
|
||||
const onSubmit = async (data: TCreateFolderFormSchema) => {
|
||||
try {
|
||||
await createFolder({
|
||||
const newFolder = await createFolder({
|
||||
name: data.name,
|
||||
parentId: folderId,
|
||||
type,
|
||||
type: FolderType.DOCUMENT,
|
||||
});
|
||||
|
||||
setIsCreateFolderOpen(false);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
await navigate(`${documentsPath}/f/${newFolder.id}`);
|
||||
|
||||
toast({
|
||||
description: t`Folder created successfully`,
|
||||
description: '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: t`Failed to create folder`,
|
||||
description: t`An unknown error occurred while creating the folder.`,
|
||||
title: 'Failed to create folder',
|
||||
description: _(msg`An unknown error occurred while creating the folder.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!isCreateFolderOpen) {
|
||||
form.reset();
|
||||
}
|
||||
setIsCreateFolderOpen(value);
|
||||
};
|
||||
}, [isCreateFolderOpen, form]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={handleOpenChange}>
|
||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<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 variant="outline" className="flex items-center space-x-2">
|
||||
<FolderPlusIcon className="h-4 w-4" />
|
||||
<span>Create Folder</span>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create New Folder</Trans>
|
||||
</DialogTitle>
|
||||
<DialogTitle>Create New Folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Enter a name for your new folder. Folders help you organise your items.</Trans>
|
||||
Enter a name for your new folder. Folders help you organise your documents.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<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)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setIsCreateFolderOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
<Button type="submit">Create</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
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';
|
||||
@@ -8,7 +11,6 @@ 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,
|
||||
@@ -30,22 +32,22 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type FolderDeleteDialogProps = {
|
||||
folder: TFolderWithSubfolders;
|
||||
folder: TFolderWithSubfolders | null;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
|
||||
|
||||
const deleteMessage = t`delete ${folder.name}`;
|
||||
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
|
||||
|
||||
const ZDeleteFolderFormSchema = z.object({
|
||||
confirmText: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: t`You must type '${deleteMessage}' to confirm` }),
|
||||
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -59,6 +61,8 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
});
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
if (!folder) return;
|
||||
|
||||
try {
|
||||
await deleteFolder({
|
||||
id: folder.id,
|
||||
@@ -67,15 +71,15 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
onOpenChange(false);
|
||||
|
||||
toast({
|
||||
title: t`Folder deleted successfully`,
|
||||
title: 'Folder deleted successfully',
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
toast({
|
||||
title: t`Folder not found`,
|
||||
description: t`The folder you are trying to delete does not exist.`,
|
||||
title: 'Folder not found',
|
||||
description: _(msg`The folder you are trying to delete does not exist.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -83,83 +87,70 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Failed to delete folder`,
|
||||
description: t`An unknown error occurred while deleting the folder.`,
|
||||
title: 'Failed to delete folder',
|
||||
description: _(msg`An unknown error occurred while deleting the folder.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
}
|
||||
onOpenChange(value);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Delete Folder</Trans>
|
||||
</DialogTitle>
|
||||
<DialogTitle>Delete Folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Are you sure you want to delete this folder?</Trans>
|
||||
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>
|
||||
|
||||
{(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)}>
|
||||
<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 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>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon, Search } from 'lucide-react';
|
||||
import { FolderIcon, HomeIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -27,7 +27,6 @@ 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 = {
|
||||
@@ -49,10 +48,9 @@ export const FolderMoveDialog = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: FolderMoveDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||
|
||||
const form = useForm<TMoveFolderFormSchema>({
|
||||
@@ -74,15 +72,15 @@ export const FolderMoveDialog = ({
|
||||
onOpenChange(false);
|
||||
|
||||
toast({
|
||||
title: t`Folder moved successfully`,
|
||||
title: 'Folder moved successfully',
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
toast({
|
||||
title: t`Folder not found`,
|
||||
description: t`The folder you are trying to move does not exist.`,
|
||||
title: 'Folder not found',
|
||||
description: _(msg`The folder you are trying to move does not exist.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -90,102 +88,79 @@ export const FolderMoveDialog = ({
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Failed to move folder`,
|
||||
description: t`An unknown error occurred while moving the folder.`,
|
||||
title: 'Failed to move folder',
|
||||
description: _(msg`An unknown error occurred while moving the folder.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
}
|
||||
onOpenChange(value);
|
||||
};
|
||||
}, [isOpen, form]);
|
||||
|
||||
// Filter out the current folder, only show folders of the same type, and filter by search term
|
||||
// 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 &&
|
||||
(searchTerm === '' || f.name.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
(f) => f.id !== folder?.id && f.type === folder?.type,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Move Folder</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Select a destination for this folder.</Trans>
|
||||
</DialogDescription>
|
||||
<DialogTitle>Move Folder</DialogTitle>
|
||||
<DialogDescription>Select a destination for this folder.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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>
|
||||
<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)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Move</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
{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>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
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';
|
||||
@@ -12,7 +14,6 @@ import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -39,7 +40,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type FolderUpdateDialogProps = {
|
||||
export type FolderSettingsDialogProps = {
|
||||
folder: TFolderWithSubfolders | null;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -52,8 +53,12 @@ export const ZUpdateFolderFormSchema = z.object({
|
||||
|
||||
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
||||
|
||||
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
export const FolderSettingsDialog = ({
|
||||
folder,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: FolderSettingsDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { toast } = useToast();
|
||||
@@ -69,10 +74,17 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
||||
if (!folder) {
|
||||
return;
|
||||
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({
|
||||
@@ -84,7 +96,7 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Folder updated successfully`,
|
||||
title: _(msg`Folder updated successfully`),
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
@@ -93,32 +105,18 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
toast({
|
||||
title: t`Folder not found`,
|
||||
title: _(msg`Folder not found`),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value && folder) {
|
||||
form.reset({
|
||||
name: folder.name,
|
||||
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
|
||||
});
|
||||
}
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Folder Settings</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Manage the settings for this folder.</Trans>
|
||||
</DialogDescription>
|
||||
<DialogTitle>Folder Settings</DialogTitle>
|
||||
<DialogDescription>Manage the settings for this folder.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
@@ -128,9 +126,7 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -145,25 +141,19 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<FormLabel>Visibility</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
<SelectValue placeholder="Select visibility" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
Managers and above
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -173,15 +163,7 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -19,7 +19,6 @@ 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';
|
||||
@@ -47,8 +46,6 @@ 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'>;
|
||||
@@ -62,11 +59,10 @@ export type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFo
|
||||
export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { refreshSession, organisations } = useSession();
|
||||
const { refreshSession } = useSession();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const actionSearchParam = searchParams?.get('action');
|
||||
|
||||
@@ -76,7 +72,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
|
||||
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
|
||||
|
||||
const [open, setOpen] = useState(actionSearchParam === 'add-organisation');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
||||
@@ -87,22 +83,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
|
||||
const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation();
|
||||
|
||||
const { data: plansData } = trpc.enterprise.billing.plans.get.useQuery(undefined, {
|
||||
enabled: IS_BILLING_ENABLED(),
|
||||
});
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
if (actionSearchParam === 'add-organisation') {
|
||||
updateSearchParams({ action: null });
|
||||
}
|
||||
}
|
||||
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
const { data: plansData } = trpc.billing.plans.get.useQuery();
|
||||
|
||||
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
|
||||
try {
|
||||
@@ -139,15 +120,23 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
}
|
||||
};
|
||||
|
||||
const isIndividualPlan = (priceId: string) => {
|
||||
return (
|
||||
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.monthlyPrice?.id === priceId ||
|
||||
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.yearlyPrice?.id === priceId
|
||||
);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (actionSearchParam === 'add-organisation') {
|
||||
setOpen(true);
|
||||
updateSearchParams({ action: null });
|
||||
}
|
||||
}, [actionSearchParam, open]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<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">
|
||||
@@ -186,15 +175,9 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{isIndividualPlan(selectedPriceId) && isPersonalLayoutMode ? (
|
||||
<IndividualPersonalLayoutCheckoutButton priceId={selectedPriceId}>
|
||||
<Trans>Checkout</Trans>
|
||||
</IndividualPersonalLayoutCheckoutButton>
|
||||
) : (
|
||||
<Button type="submit" onClick={() => setStep('create')}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" onClick={() => setStep('create')}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</>
|
||||
@@ -312,23 +295,16 @@ const BillingPlanForm = ({
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [plans, t]);
|
||||
}, [plans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === '' && !canCreateFreeOrganisation && dynamicPlans.length > 0) {
|
||||
const defaultValue = dynamicPlans[0][billingPeriod]?.id ?? '';
|
||||
if (defaultValue) {
|
||||
onChange(defaultValue);
|
||||
}
|
||||
if (value === '' && !canCreateFreeOrganisation) {
|
||||
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
|
||||
}
|
||||
}, [canCreateFreeOrganisation, dynamicPlans, billingPeriod, onChange, value]);
|
||||
}, [value]);
|
||||
|
||||
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
||||
const plan = dynamicPlans.find(
|
||||
(plan) =>
|
||||
// Purposely using the opposite billing period to get the correct plan.
|
||||
plan[billingPeriod === 'monthlyPrice' ? 'yearlyPrice' : 'monthlyPrice']?.id === value,
|
||||
);
|
||||
const plan = dynamicPlans.find((plan) => plan[billingPeriod]?.id === value);
|
||||
|
||||
setBillingPeriod(billingPeriod);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -93,19 +93,14 @@ export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogPr
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (form.formState.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
setOpen(value);
|
||||
};
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="destructive">
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { 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();
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
if (!isPending) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,117 +0,0 @@
|
||||
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 handleOpenChange = (value: boolean) => {
|
||||
if (!isDeleting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
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={handleOpenChange}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,197 +0,0 @@
|
||||
import { 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();
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
setStep('domain');
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,161 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,139 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,181 +0,0 @@
|
||||
import { 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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
form.reset({
|
||||
emailName: organisationEmail.emailName,
|
||||
// replyTo: organisationEmail.replyTo ?? undefined,
|
||||
});
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@@ -117,17 +117,16 @@ export const OrganisationGroupCreateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -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, SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
||||
import { IS_BILLING_ENABLED } 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';
|
||||
@@ -193,6 +193,13 @@ export const OrganisationMemberInviteDialog = ({
|
||||
return 'form';
|
||||
}, [fullOrganisation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setInvitationType('INDIVIDUAL');
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) {
|
||||
return;
|
||||
@@ -260,18 +267,12 @@ export const OrganisationMemberInviteDialog = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
setInvitationType('INDIVIDUAL');
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
@@ -302,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_EMAIL}`}>{SUPPORT_EMAIL}</a> if you
|
||||
would like to discuss your options.
|
||||
contact sales at <a href="mailto:support@documenso.com">support@documenso.com</a>{' '}
|
||||
if you would like to discuss your options.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -106,27 +106,32 @@ export const OrganisationMemberUpdateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
form.reset();
|
||||
if (
|
||||
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
|
||||
) {
|
||||
setOpen(false);
|
||||
toast({
|
||||
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
|
||||
form.reset();
|
||||
|
||||
if (
|
||||
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
|
||||
) {
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -120,21 +120,24 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
||||
return passkeyName;
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const defaultPasskeyName = extractDefaultPasskeyName();
|
||||
|
||||
form.reset({
|
||||
passkeyName: defaultPasskeyName,
|
||||
});
|
||||
|
||||
setFormError(null);
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleDialogOpenChange}>
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary" loading={isPending}>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type SessionLogoutAllDialogProps = {
|
||||
onSuccess?: () => Promise<unknown>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SessionLogoutAllDialog = ({ onSuccess, disabled }: SessionLogoutAllDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSignOutAllSessions = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await authClient.signOutAllSessions();
|
||||
|
||||
if (onSuccess) {
|
||||
await onSuccess();
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Sessions have been revoked`,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`Failed to sign out all sessions`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(value) => (isLoading ? undefined : setIsOpen(value))}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" disabled={disabled}>
|
||||
<Trans>Revoke all sessions</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Revoke all sessions</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
This will sign you out of all other devices. You will need to sign in again on those
|
||||
devices to continue using your account.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary" disabled={isLoading}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button loading={isLoading} variant="destructive" onClick={handleSignOutAllSessions}>
|
||||
<Trans>Revoke all sessions</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -12,11 +12,7 @@ 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,
|
||||
SUPPORT_EMAIL,
|
||||
} from '@documenso/lib/constants/app';
|
||||
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } 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';
|
||||
@@ -66,14 +62,14 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const actionSearchParam = searchParams?.get('action');
|
||||
const shouldOpenDialog = actionSearchParam === 'add-team';
|
||||
const [open, setOpen] = useState(shouldOpenDialog);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.id,
|
||||
});
|
||||
|
||||
const actionSearchParam = searchParams?.get('action');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZCreateTeamFormSchema),
|
||||
defaultValues: {
|
||||
@@ -85,18 +81,6 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
|
||||
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
if (shouldOpenDialog) {
|
||||
updateSearchParams({ action: null });
|
||||
}
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
|
||||
try {
|
||||
await createTeam({
|
||||
@@ -162,8 +146,23 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
return 'form';
|
||||
}, [fullOrganisation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionSearchParam === 'add-team') {
|
||||
setOpen(true);
|
||||
updateSearchParams({ action: null });
|
||||
}
|
||||
}, [actionSearchParam, open]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<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">
|
||||
@@ -194,8 +193,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_EMAIL}`}>{SUPPORT_EMAIL}</a> if you would like to
|
||||
adjust your plan.
|
||||
at <a href="mailto:support@documenso.com">support@documenso.com</a> if you would
|
||||
like to adjust your plan.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -114,17 +114,14 @@ export const TeamDeleteDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="destructive">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -103,17 +103,18 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" loading={isPending} className="bg-background">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -92,17 +92,18 @@ export const TeamEmailUpdateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" className="bg-background">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
@@ -107,7 +107,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
handleClose();
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
@@ -117,23 +117,17 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
setStep('SELECT');
|
||||
};
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setStep('SELECT');
|
||||
}
|
||||
};
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
||||
// Since it would be annoying to redo the whole process.
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -106,17 +106,22 @@ export const TeamGroupUpdateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!form.formState.isSubmitting) {
|
||||
if (value) {
|
||||
form.reset();
|
||||
}
|
||||
setOpen(value);
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
form.reset();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
@@ -41,7 +39,6 @@ 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';
|
||||
@@ -119,17 +116,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setStep('SELECT');
|
||||
}
|
||||
// Disable automatic onOpenChange events to prevent dialog from closing if user 'accidentally' clicks the overlay.
|
||||
// Since it would be annoying to redo the whole process, we handle open state manually
|
||||
};
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
||||
// Since it would be annoying to redo the whole process.
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
||||
<Trans>Add members</Trans>
|
||||
@@ -140,28 +140,8 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
{match(step)
|
||||
.with('SELECT', () => (
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex flex-row items-center">
|
||||
<DialogTitle>
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -106,25 +106,30 @@ export const TeamMemberUpdateDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
form.reset();
|
||||
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
||||
setOpen(false);
|
||||
toast({
|
||||
title: _(msg`You cannot modify a team member who has a higher role than you.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
|
||||
form.reset();
|
||||
|
||||
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
title: _(msg`You cannot modify a team member who has a higher role than you.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
|
||||
@@ -8,7 +8,6 @@ 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 {
|
||||
@@ -24,21 +23,18 @@ 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 = ({ folderId }: TemplateCreateDialogProps) => {
|
||||
export const TemplateCreateDialog = ({ templateRootPath, 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);
|
||||
@@ -70,7 +66,7 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
||||
|
||||
setShowTemplateCreateDialog(false);
|
||||
|
||||
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
||||
await navigate(`${templateRootPath}/${id}/edit`);
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -186,20 +186,16 @@ export const TemplateDirectLinkDialog = ({
|
||||
const isLoading =
|
||||
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!isLoading) {
|
||||
if (value) {
|
||||
resetCreateTemplateDirectLink();
|
||||
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
||||
setSelectedRecipientId(null);
|
||||
}
|
||||
useEffect(() => {
|
||||
resetCreateTemplateDirectLink();
|
||||
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
||||
setSelectedRecipientId(null);
|
||||
|
||||
onOpenChange(value);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<fieldset disabled={isLoading} className="relative">
|
||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||
{match({ token, currentStep })
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
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 { useState } from 'react';
|
||||
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 { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
@@ -31,7 +31,6 @@ 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';
|
||||
@@ -60,12 +59,9 @@ 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: {
|
||||
@@ -85,6 +81,14 @@ export function TemplateMoveToFolderDialog({
|
||||
|
||||
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId ?? null });
|
||||
}
|
||||
}, [isOpen, currentFolderId, form]);
|
||||
|
||||
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
||||
try {
|
||||
await moveTemplateToFolder({
|
||||
@@ -128,22 +132,8 @@ export function TemplateMoveToFolderDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId ?? null });
|
||||
}
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
const filteredFolders = folders?.data?.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -155,16 +145,6 @@ 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
|
||||
@@ -175,9 +155,8 @@ export function TemplateMoveToFolderDialog({
|
||||
<FormLabel>
|
||||
<Trans>Folder</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{isFoldersLoading ? (
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -192,10 +171,10 @@ export function TemplateMoveToFolderDialog({
|
||||
disabled={currentFolderId === null}
|
||||
>
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Home (No Folder)</Trans>
|
||||
<Trans>Root (No Folder)</Trans>
|
||||
</Button>
|
||||
|
||||
{filteredFolders?.map((folder) => (
|
||||
{folders?.data?.map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
@@ -208,12 +187,6 @@ 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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -15,7 +15,6 @@ 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';
|
||||
@@ -203,17 +202,14 @@ export function TemplateUseDialog({
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (form.formState.isSubmitting) return;
|
||||
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
setOpen(value);
|
||||
};
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" className="bg-background">
|
||||
@@ -283,11 +279,7 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={
|
||||
isTemplateRecipientEmailPlaceholder(field.value)
|
||||
? ''
|
||||
: _(msg`Email`)
|
||||
}
|
||||
placeholder={recipients[index].email || _(msg`Email`)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -492,7 +484,6 @@ 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) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -95,17 +95,17 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setIsOpen(value);
|
||||
}
|
||||
};
|
||||
}, [isOpen, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
|
||||
>
|
||||
<DialogTrigger asChild={true}>
|
||||
{children ?? (
|
||||
<Button className="mr-4" variant="destructive">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -88,17 +88,14 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
if (!form.formState.isSubmitting) {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{children ?? (
|
||||
<Button className="mr-4" variant="destructive">
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -32,14 +32,7 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
|
||||
|
||||
export type EmbedDocumentFieldsProps = {
|
||||
fields: Field[];
|
||||
metadata?: Pick<
|
||||
DocumentMeta | TemplateMeta,
|
||||
| 'timezone'
|
||||
| 'dateFormat'
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
> | null;
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
export const EmbedDocumentWaitingForTurn = () => {
|
||||
const [hasPostedMessage, setHasPostedMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.parent) {
|
||||
if (window.parent && !hasPostedMessage) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-waiting-for-turn',
|
||||
@@ -13,7 +15,13 @@ export const EmbedDocumentWaitingForTurn = () => {
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
setHasPostedMessage(true);
|
||||
}, [hasPostedMessage]);
|
||||
|
||||
if (!hasPostedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -136,19 +136,18 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
useEffect(() => {
|
||||
enable2FAForm.reset();
|
||||
|
||||
if (!open) {
|
||||
enable2FAForm.reset();
|
||||
if (recoveryCodes && recoveryCodes.length > 0) {
|
||||
setRecoveryCodes(null);
|
||||
}
|
||||
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
||||
setRecoveryCodes(null);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild={true}>
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
|
||||
@@ -8,24 +8,17 @@ 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,
|
||||
@@ -51,11 +44,8 @@ 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[];
|
||||
};
|
||||
|
||||
@@ -63,11 +53,8 @@ type SettingsSubset = Pick<
|
||||
TeamGlobalSettings,
|
||||
| 'documentVisibility'
|
||||
| 'documentLanguage'
|
||||
| 'documentTimezone'
|
||||
| 'documentDateFormat'
|
||||
| 'includeSenderDetails'
|
||||
| 'includeSigningCertificate'
|
||||
| 'includeAuditLog'
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
@@ -94,11 +81,8 @@ 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,
|
||||
}),
|
||||
@@ -110,12 +94,8 @@ 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),
|
||||
@@ -144,10 +124,7 @@ 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"
|
||||
data-testid="document-visibility-trigger"
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -194,10 +171,7 @@ 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"
|
||||
data-testid="document-language-trigger"
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -225,72 +199,6 @@ 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"
|
||||
@@ -314,7 +222,7 @@ export const DocumentPreferencesForm = ({
|
||||
emptySelectionPlaceholder={
|
||||
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
||||
}
|
||||
testId="signature-types-trigger"
|
||||
testId="signature-types-combobox"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -349,10 +257,7 @@ export const DocumentPreferencesForm = ({
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="include-sender-details-trigger"
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -420,10 +325,7 @@ export const DocumentPreferencesForm = ({
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="include-signing-certificate-trigger"
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -456,56 +358,6 @@ 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>
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
@@ -116,18 +116,9 @@ export const SignInForm = ({
|
||||
const { mutateAsync: createPasskeySigninOptions } =
|
||||
trpc.auth.createPasskeySigninOptions.useMutation();
|
||||
|
||||
const emailFromHash = useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const hash = window.location.hash.slice(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
return params.get('email');
|
||||
}, []);
|
||||
|
||||
const form = useForm<TSignInFormSchema>({
|
||||
values: {
|
||||
email: emailFromHash ?? initialEmail ?? '',
|
||||
email: initialEmail ?? '',
|
||||
password: '',
|
||||
totpCode: '',
|
||||
backupCode: '',
|
||||
@@ -296,6 +287,18 @@ export const SignInForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
const email = params.get('email');
|
||||
|
||||
if (email) {
|
||||
form.setValue('email', email);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
@@ -84,19 +84,10 @@ export const SignUpForm = ({
|
||||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const emailFromHash = useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const hash = window.location.hash.slice(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
return params.get('email');
|
||||
}, []);
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
name: '',
|
||||
email: emailFromHash ?? initialEmail ?? '',
|
||||
email: initialEmail ?? '',
|
||||
password: '',
|
||||
signature: '',
|
||||
},
|
||||
@@ -171,6 +162,18 @@ export const SignUpForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
const email = params.get('email');
|
||||
|
||||
if (email) {
|
||||
form.setValue('email', email);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex justify-center gap-x-12', className)}>
|
||||
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
'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,13 +1,11 @@
|
||||
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';
|
||||
|
||||
@@ -30,15 +28,6 @@ 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);
|
||||
@@ -72,11 +61,12 @@ 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" />
|
||||
|
||||
{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}
|
||||
{/* 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
|
||||
</span>
|
||||
)}
|
||||
)} */}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -2,14 +2,12 @@ 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';
|
||||
|
||||
@@ -27,15 +25,6 @@ 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);
|
||||
};
|
||||
@@ -63,11 +52,11 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
return [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
text: t`Documents`,
|
||||
label: t`Documents`,
|
||||
},
|
||||
{
|
||||
href: `/t/${teamUrl}/templates`,
|
||||
text: t`Templates`,
|
||||
label: t`Templates`,
|
||||
},
|
||||
{
|
||||
href: '/inbox',
|
||||
@@ -97,16 +86,11 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
{menuNavigationLinks.map(({ href, text }) => (
|
||||
<Link
|
||||
key={href}
|
||||
className="text-foreground hover:text-foreground/80 flex items-center gap-2 text-2xl font-semibold"
|
||||
className="text-foreground hover:text-foreground/80 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,9 +10,7 @@ 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';
|
||||
@@ -51,12 +49,8 @@ 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 = [];
|
||||
|
||||
@@ -132,18 +126,12 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
<BillingDialog
|
||||
priceId={price.id}
|
||||
planName={price.product.name}
|
||||
memberCount={price.memberCount}
|
||||
claim={price.claim}
|
||||
/>
|
||||
</CardContent>
|
||||
</MotionCard>
|
||||
))}
|
||||
@@ -186,7 +174,7 @@ const BillingDialog = ({
|
||||
});
|
||||
|
||||
const { mutateAsync: createSubscription, isPending: isCreatingSubscription } =
|
||||
trpc.enterprise.billing.subscription.create.useMutation();
|
||||
trpc.billing.subscription.create.useMutation();
|
||||
|
||||
const { mutateAsync: createOrganisation, isPending: isCreatingOrganisation } =
|
||||
trpc.organisation.create.useMutation();
|
||||
@@ -327,48 +315,3 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { AUTO_SIGNABLE_FIELD_TYPES } from '@documenso/lib/constants/autosign';
|
||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
||||
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -30,6 +30,13 @@ 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
|
||||
@@ -60,6 +67,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
const { email, fullName } = useRequiredDocumentSigningContext();
|
||||
const { derivedRecipientActionAuth } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm();
|
||||
|
||||
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
|
||||
|
||||
const autoSignableFields = fields.filter((field) => {
|
||||
if (field.inserted) {
|
||||
return false;
|
||||
@@ -88,14 +101,6 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
(actionAuth) => !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(actionAuth),
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(() => {
|
||||
return actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD;
|
||||
});
|
||||
|
||||
const form = useForm();
|
||||
|
||||
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
|
||||
|
||||
const onSubmit = async () => {
|
||||
const results = await Promise.allSettled(
|
||||
autoSignableFields.map(async (field) => {
|
||||
@@ -153,6 +158,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
await revalidate();
|
||||
};
|
||||
|
||||
unsafe_useEffectOnce(() => {
|
||||
if (actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD) {
|
||||
setOpen(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -17,7 +17,6 @@ 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';
|
||||
@@ -277,14 +276,7 @@ export const DocumentSigningCheckboxField = ({
|
||||
{validationSign?.label} {checkboxValidationLength}
|
||||
</FieldToolTip>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'z-50 my-0.5 flex gap-1',
|
||||
parsedFieldMeta.direction === 'horizontal'
|
||||
? 'flex-row flex-wrap'
|
||||
: 'flex-col gap-y-1',
|
||||
)}
|
||||
>
|
||||
<div className="z-50 my-0.5 flex flex-col gap-y-1">
|
||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||
const itemValue = item.value || `empty-value-${item.id}`;
|
||||
|
||||
@@ -294,7 +286,6 @@ 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 && (
|
||||
@@ -313,12 +304,7 @@ export const DocumentSigningCheckboxField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<div
|
||||
className={cn(
|
||||
'my-0.5 flex gap-1',
|
||||
parsedFieldMeta.direction === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col gap-y-1',
|
||||
)}
|
||||
>
|
||||
<div className="my-0.5 flex flex-col gap-y-1">
|
||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||
const itemValue = item.value || `empty-value-${item.id}`;
|
||||
|
||||
@@ -328,7 +314,7 @@ export const DocumentSigningCheckboxField = ({
|
||||
className="h-3 w-3"
|
||||
id={`checkbox-${field.id}-${item.id}`}
|
||||
checked={parsedCheckedValues.includes(itemValue)}
|
||||
disabled={isLoading || isReadOnly}
|
||||
disabled={isLoading}
|
||||
onCheckedChange={() => void handleCheckboxOptionClick(item)}
|
||||
/>
|
||||
{!item.value.includes('empty-value-') && item.value && (
|
||||
|
||||
@@ -131,12 +131,7 @@ export const DocumentSigningFieldContainer = ({
|
||||
|
||||
return (
|
||||
<div className={cn('[container-type:size]')}>
|
||||
<FieldRootContainer
|
||||
color={
|
||||
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
|
||||
}
|
||||
field={field}
|
||||
>
|
||||
<FieldRootContainer color={RECIPIENT_COLOR_STYLES.green} field={field}>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
type="submit"
|
||||
@@ -145,6 +140,14 @@ 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="text-foreground group-hover:text-recipient-green whitespace-pre-wrap text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green 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 overflow-hidden">
|
||||
<div className="flex h-full w-full items-center">
|
||||
<p
|
||||
className={cn(
|
||||
'text-foreground w-full whitespace-pre-wrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
'text-foreground w-full text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
{
|
||||
'!text-center': textAlign === 'center',
|
||||
'!text-right': textAlign === 'right',
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -176,7 +177,15 @@ export const DocumentSigningForm = ({
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<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,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
@@ -185,8 +194,21 @@ 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">
|
||||
@@ -223,6 +245,15 @@ 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"
|
||||
@@ -309,76 +340,88 @@ export const DocumentSigningForm = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
{hasSignatureField && (
|
||||
<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="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</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}
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</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, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
const { recipient, targetSigner, 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) : '',
|
||||
const [localNumber, setLocalNumber] = useState(
|
||||
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
|
||||
);
|
||||
|
||||
const initialErrors: ValidationErrors = {
|
||||
@@ -213,13 +213,16 @@ export const DocumentSigningNumberField = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNumberModal) {
|
||||
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '');
|
||||
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
|
||||
setErrors(initialErrors);
|
||||
}
|
||||
}, [showNumberModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!field.inserted && defaultValue) {
|
||||
if (
|
||||
(!field.inserted && defaultValue && localNumber) ||
|
||||
(!field.inserted && parsedFieldMeta?.readOnly && defaultValue)
|
||||
) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
@@ -315,7 +318,7 @@ export const DocumentSigningNumberField = ({
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowNumberModal(false);
|
||||
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '');
|
||||
setLocalNumber('');
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -21,7 +20,6 @@ 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';
|
||||
@@ -64,7 +62,6 @@ 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})`;
|
||||
@@ -75,20 +72,18 @@ 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={targetSigner}>
|
||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<h1
|
||||
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<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="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="text-muted-foreground truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
@@ -138,79 +133,26 @@ export const DocumentSigningPageView = ({
|
||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
||||
</div>
|
||||
|
||||
<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
|
||||
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="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
|
||||
>
|
||||
<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>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ 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}`,
|
||||
@@ -165,7 +164,6 @@ 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
|
||||
@@ -189,7 +187,6 @@ 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,6 +38,11 @@ export const DocumentSigningRecipientProvider = ({
|
||||
recipient,
|
||||
targetSigner = null,
|
||||
}: DocumentSigningRecipientProviderProps) => {
|
||||
// console.log({
|
||||
// recipient,
|
||||
// targetSigner,
|
||||
// isAssistantMode: !!targetSigner,
|
||||
// });
|
||||
return (
|
||||
<DocumentSigningRecipientContext.Provider
|
||||
value={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -80,16 +80,20 @@ export const DocumentSigningTextField = ({
|
||||
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
|
||||
const shouldAutoSignField = useMemo(
|
||||
() =>
|
||||
(!field.inserted && parsedFieldMeta?.text) ||
|
||||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly),
|
||||
[field.inserted, parsedFieldMeta?.text, parsedFieldMeta?.readOnly],
|
||||
);
|
||||
const shouldAutoSignField =
|
||||
(!field.inserted && parsedFieldMeta?.text) ||
|
||||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly);
|
||||
|
||||
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
||||
const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCustomTextModal) {
|
||||
setLocalCustomText(parsedFieldMeta?.text ?? '');
|
||||
setErrors(initialErrors);
|
||||
}
|
||||
}, [showCustomTextModal]);
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value;
|
||||
setLocalCustomText(text);
|
||||
@@ -212,17 +216,30 @@ export const DocumentSigningTextField = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (shouldAutoSignField) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
if (shouldAutoSignField) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
|
||||
|
||||
const labelDisplay = parsedField?.label;
|
||||
const textDisplay = parsedField?.text;
|
||||
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 fieldDisplayName = labelDisplay ? labelDisplay : textDisplay;
|
||||
const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);
|
||||
@@ -245,7 +262,9 @@ export const DocumentSigningTextField = ({
|
||||
|
||||
{field.inserted && (
|
||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||
{field.customText}
|
||||
{field.customText.length < 20
|
||||
? field.customText
|
||||
: field.customText.substring(0, 20) + '...'}
|
||||
</DocumentSigningFieldsInserted>
|
||||
)}
|
||||
|
||||
@@ -312,8 +331,7 @@ export const DocumentSigningTextField = ({
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowCustomTextModal(false);
|
||||
setLocalCustomText(parsedFieldMeta?.text ?? '');
|
||||
setErrors(initialErrors);
|
||||
setLocalCustomText('');
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
||||
timezone: userTimezone,
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -13,7 +12,6 @@ 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';
|
||||
@@ -177,17 +175,13 @@ 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: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
globalAccessAuth: data.globalAccessAuth ?? [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
@@ -278,8 +272,7 @@ export const DocumentEditForm = ({
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
|
||||
data.meta;
|
||||
const { subject, message, distributionMethod, emailSettings } = data.meta;
|
||||
|
||||
try {
|
||||
await sendDocument({
|
||||
@@ -288,9 +281,7 @@ export const DocumentEditForm = ({
|
||||
subject,
|
||||
message,
|
||||
distributionMethod,
|
||||
emailId,
|
||||
emailReplyTo: emailReplyTo || null,
|
||||
emailSettings: emailSettings,
|
||||
emailSettings,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,410 @@
|
||||
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 Logs</Trans>
|
||||
<Trans>Audit Log</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { useRevalidator } from 'react-router';
|
||||
|
||||
import { isDocumentBeingProcessed } from '@documenso/lib/utils/document';
|
||||
|
||||
type DocumentType = {
|
||||
id: number;
|
||||
status: DocumentStatus;
|
||||
deletedAt: Date | null;
|
||||
recipients: Array<{
|
||||
role: RecipientRole;
|
||||
signingStatus: SigningStatus;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type DocumentProcessingPollProps = {
|
||||
documents?: DocumentType[] | DocumentType;
|
||||
};
|
||||
|
||||
export const DocumentProcessingPoll = ({ documents }: DocumentProcessingPollProps) => {
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
useEffect(() => {
|
||||
if (!documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentArray = Array.isArray(documents) ? documents : [documents];
|
||||
|
||||
if (documentArray.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasProcessingDocuments = documentArray.some((document) =>
|
||||
isDocumentBeingProcessed(document),
|
||||
);
|
||||
|
||||
if (!hasProcessingDocuments) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (window.document.hasFocus()) {
|
||||
void revalidate();
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [documents, revalidate]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -26,13 +26,12 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string })
|
||||
|
||||
setSearchParams(params);
|
||||
},
|
||||
[searchParams, setSearchParams],
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const currentQuery = searchParams?.get('query') ?? '';
|
||||
if (currentQuery !== debouncedSearchTerm) {
|
||||
handleSearch(debouncedSearchTerm);
|
||||
}
|
||||
useEffect(() => {
|
||||
handleSearch(searchTerm);
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, Clock, File, Loader2, XCircle } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
@@ -15,6 +15,7 @@ type FriendlyStatus = {
|
||||
labelExtended: MessageDescriptor;
|
||||
icon?: LucideIcon;
|
||||
color: string;
|
||||
animate?: boolean;
|
||||
};
|
||||
|
||||
export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
||||
@@ -55,20 +56,31 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
||||
},
|
||||
};
|
||||
|
||||
const PROCESSING_STATUS: FriendlyStatus = {
|
||||
label: msg`Processing`,
|
||||
labelExtended: msg`Document processing`,
|
||||
icon: Loader2,
|
||||
color: 'text-blue-600 dark:text-blue-300',
|
||||
animate: true,
|
||||
};
|
||||
|
||||
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
status: ExtendedDocumentStatus;
|
||||
inheritColor?: boolean;
|
||||
isProcessing?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentStatus = ({
|
||||
className,
|
||||
status,
|
||||
inheritColor,
|
||||
isProcessing,
|
||||
...props
|
||||
}: DocumentStatusProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status];
|
||||
const statusConfig = isProcessing ? PROCESSING_STATUS : FRIENDLY_STATUS_MAP[status];
|
||||
const { label, icon: Icon, color, animate } = statusConfig;
|
||||
|
||||
return (
|
||||
<span className={cn('flex items-center', className)} {...props}>
|
||||
@@ -76,6 +88,7 @@ export const DocumentStatus = ({
|
||||
<Icon
|
||||
className={cn('mr-2 inline-block h-4 w-4', {
|
||||
[color]: !inheritColor,
|
||||
'animate-spin': animate,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -78,7 +78,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
||||
timezone: userTimezone,
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
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 { FolderIcon, PinIcon } from 'lucide-react';
|
||||
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatFolderCount } from '@documenso/lib/utils/format-folder-count';
|
||||
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;
|
||||
@@ -36,132 +23,66 @@ 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 (
|
||||
<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>
|
||||
</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
|
||||
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>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||