mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 03:32:14 +10:00
chore: merge main
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,7 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
packages/prisma/generated/types.ts
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
node_modules
|
||||||
.pnp
|
.pnp
|
||||||
|
|||||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@ -5,12 +5,7 @@
|
|||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll": "explicit"
|
"source.fixAll": "explicit"
|
||||||
},
|
},
|
||||||
"eslint.validate": [
|
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||||
"typescript",
|
|
||||||
"typescriptreact",
|
|
||||||
"javascript",
|
|
||||||
"javascriptreact"
|
|
||||||
],
|
|
||||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"javascript.preferences.useAliasesForRenames": false,
|
"javascript.preferences.useAliasesForRenames": false,
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
@ -20,4 +15,7 @@
|
|||||||
"[prisma]": {
|
"[prisma]": {
|
||||||
"editor.defaultFormatter": "Prisma.prisma"
|
"editor.defaultFormatter": "Prisma.prisma"
|
||||||
},
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -261,6 +261,7 @@ npm run prisma:migrate-deploy
|
|||||||
Finally, you can start it with:
|
Finally, you can start it with:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
cd apps/web
|
||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@ To create a new webhook subscription, you need to provide the following informat
|
|||||||
|
|
||||||
- Enter the webhook URL that will receive the event payload.
|
- Enter the webhook URL that will receive the event payload.
|
||||||
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`.
|
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`.
|
||||||
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Signature` header of the request.
|
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@ -11,14 +11,7 @@ tags:
|
|||||||
- Compliance
|
- Compliance
|
||||||
---
|
---
|
||||||
|
|
||||||
<video
|
<video id="vid" width="100%" src="/blog/vial.webm" autoPlay loop muted></video>
|
||||||
id="vid"
|
|
||||||
width="100%"
|
|
||||||
src="/blog/vial.webm"
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
></video>
|
|
||||||
<figcaption className="text-center">
|
<figcaption className="text-center">
|
||||||
Vial.com uses Documenso for 21 CFR Part 11 compliant signing.
|
Vial.com uses Documenso for 21 CFR Part 11 compliant signing.
|
||||||
</figcaption>
|
</figcaption>
|
||||||
@ -26,42 +19,40 @@ tags:
|
|||||||
> TLDR; We launched Vial.com on Documenso and are open for 21 CFR Part 11 business.
|
> TLDR; We launched Vial.com on Documenso and are open for 21 CFR Part 11 business.
|
||||||
|
|
||||||
# What is 21 CFR
|
# What is 21 CFR
|
||||||
|
|
||||||
You have never heard of 21 CFR Part 11? You are in good company since most people haven't. If you have, you probably work in an industry regulated by the U.S. Food and Drug Administration (FDA). Title 21 of the Code of Federal Regulations (CFR) is dedicated to detailing FDA-regulated business, and sub-part 11 sets out guidelines for using electronic signatures in this highly regulated field. Hence, 21 CFR Part 11 is highly relevant for regulated industries that aim to employ digital signatures. The guidelines set out in 21 CFR Part 11 aim to provide trustworthy, reliable, and equivalent to paper records and handwritten signatures. All Industries that fall under the FDA's regulation, e.g. pharmaceuticals, biotechnology, medical devices, and biologics, must comply with these rules when choosing or creating systems for electronic signatures.
|
You have never heard of 21 CFR Part 11? You are in good company since most people haven't. If you have, you probably work in an industry regulated by the U.S. Food and Drug Administration (FDA). Title 21 of the Code of Federal Regulations (CFR) is dedicated to detailing FDA-regulated business, and sub-part 11 sets out guidelines for using electronic signatures in this highly regulated field. Hence, 21 CFR Part 11 is highly relevant for regulated industries that aim to employ digital signatures. The guidelines set out in 21 CFR Part 11 aim to provide trustworthy, reliable, and equivalent to paper records and handwritten signatures. All Industries that fall under the FDA's regulation, e.g. pharmaceuticals, biotechnology, medical devices, and biologics, must comply with these rules when choosing or creating systems for electronic signatures.
|
||||||
|
|
||||||
Compliance with 21 CFR Part 11 is crucial for companies to use electronic records and signatures in their operations legally. It affects how companies manage documentation, conduct audits, and maintain regulatory submissions. Non-compliance can result in legal penalties, rejected submissions, and delays in product approvals, emphasizing the importance of adhering to these guidelines in FDA-regulated activities.
|
Compliance with 21 CFR Part 11 is crucial for companies to use electronic records and signatures in their operations legally. It affects how companies manage documentation, conduct audits, and maintain regulatory submissions. Non-compliance can result in legal penalties, rejected submissions, and delays in product approvals, emphasizing the importance of adhering to these guidelines in FDA-regulated activities.
|
||||||
|
|
||||||
# Vial.com
|
# Vial.com
|
||||||
|
|
||||||
Vial is a technology company on a mission to advance programs to market through computationally designed therapeutics and cost-effective clinical trials. It is imperative that Vial manages this process securely, effectively, and highly compliant. By leveraging it's modern platform, Vial aims to accelerate drug development and, ultimately, time to market for new therapies. You can learn more about them [here](https://vial.com/about-us).
|
Vial is a technology company on a mission to advance programs to market through computationally designed therapeutics and cost-effective clinical trials. It is imperative that Vial manages this process securely, effectively, and highly compliant. By leveraging it's modern platform, Vial aims to accelerate drug development and, ultimately, time to market for new therapies. You can learn more about them [here](https://vial.com/about-us).
|
||||||
|
|
||||||
[Together](https://documen.so/vial-documenso), Documenso and Vial set out to create the first open-source, 21 CFR Part 11 compliant signing solution. After iterating over the product together, Vial moved their operation from DocuSign, a known legacy signing provider, to a Documenso Enterprise plan. We are very happy to be able to support Vial’s mission by fulfilling our own: bringing open signing and all its innovation to where it's needed.
|
[Together](https://documen.so/vial-documenso), Documenso and Vial set out to create the first open-source, 21 CFR Part 11 compliant signing solution. After iterating over the product together, Vial moved their operation from DocuSign, a known legacy signing provider, to a Documenso Enterprise plan. We are very happy to be able to support Vial’s mission by fulfilling our own: bringing open signing and all its innovation to where it's needed.
|
||||||
|
|
||||||
# 21 CFR Part 11 on Documenso Highlights
|
# 21 CFR Part 11 on Documenso Highlights
|
||||||
|
|
||||||
21 CFR Part 11 is a highly complex statute, and going into the all design rationales and the following implementation details, deserves its own article later. For now, I want to share a few notable highlights.
|
21 CFR Part 11 is a highly complex statute, and going into the all design rationales and the following implementation details, deserves its own article later. For now, I want to share a few notable highlights.
|
||||||
|
|
||||||
## The Full Experience
|
## The Full Experience
|
||||||
|
|
||||||
We implemented 21 CFR Part 11, keeping the main user experience of Documenso intact. Our 21 CFR module is not separate but natively integrated into all Documenso flows, thus not sacrificing usability for compliance. This also means most (if not all) advanced features we offer are usable in a compliant way. This prevents customers from being trapped in an anti-innovation bubble, not allowing access to new features for fear of non-compliance.
|
We implemented 21 CFR Part 11, keeping the main user experience of Documenso intact. Our 21 CFR module is not separate but natively integrated into all Documenso flows, thus not sacrificing usability for compliance. This also means most (if not all) advanced features we offer are usable in a compliant way. This prevents customers from being trapped in an anti-innovation bubble, not allowing access to new features for fear of non-compliance.
|
||||||
|
|
||||||
## Action Reauth Using Passkeys
|
## Action Reauth Using Passkeys
|
||||||
<video
|
|
||||||
id="vid"
|
<video id="vid" width="100%" src="/blog/vial2.webm" autoPlay loop muted controls></video>
|
||||||
width="100%"
|
|
||||||
src="/blog/vial2.webm"
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
controls
|
|
||||||
></video>
|
|
||||||
<figcaption className="text-center">
|
<figcaption className="text-center">
|
||||||
Using passkeys (used here via fingerprint scanner) is the smoothest way to re-authenticate.
|
Using passkeys (used here via fingerprint scanner) is the smoothest way to re-authenticate.
|
||||||
</figcaption>
|
</figcaption>
|
||||||
|
|
||||||
|
|
||||||
One of the requirements affecting day-to-day life the most is the requirement to actually reauthenticate every signature placed on a document. While we can't change that, we can help make the reauthentication as painless as possible. To this end, we opted for passkeys. While Documenso supports passkeys to log in, they are also supported to authenticate signing on a per-signature level as part of the Documenso Enterprise Plan. The user still has to authenticate every signature but can now do so from the comfort of their passkey provider, be that 1Password, their browser, or any other provider.
|
One of the requirements affecting day-to-day life the most is the requirement to actually reauthenticate every signature placed on a document. While we can't change that, we can help make the reauthentication as painless as possible. To this end, we opted for passkeys. While Documenso supports passkeys to log in, they are also supported to authenticate signing on a per-signature level as part of the Documenso Enterprise Plan. The user still has to authenticate every signature but can now do so from the comfort of their passkey provider, be that 1Password, their browser, or any other provider.
|
||||||
|
|
||||||
## Direct Links
|
## Direct Links
|
||||||
|
|
||||||
We recently launched [Direct Template Links](https://documen.so/direct-links), a new way to let people sign and fill out forms. Links can be completed anytime, creating a new document in the process. Direct Links are also 21 CFR part 11 compliant, using action reauthentication, audit log, and all other compliance requirements.
|
We recently launched [Direct Template Links](https://documen.so/direct-links), a new way to let people sign and fill out forms. Links can be completed anytime, creating a new document in the process. Direct Links are also 21 CFR part 11 compliant, using action reauthentication, audit log, and all other compliance requirements.
|
||||||
|
|
||||||
# Documenso Enterprise Plan
|
# Documenso Enterprise Plan
|
||||||
|
|
||||||
With the successful launch of Vial, we are now open for business. 21 CFR Part 11 compliance is part of the Documenso Enterprise plan, which includes all regulations we currently support and upcoming additions. While the pricing depends heavily on your needs and scale, we offer fixed-price plans for better predictability for both sides. In our experience, volume-based pricing is a legacy headache we want to avoid.
|
With the successful launch of Vial, we are now open for business. 21 CFR Part 11 compliance is part of the Documenso Enterprise plan, which includes all regulations we currently support and upcoming additions. While the pricing depends heavily on your needs and scale, we offer fixed-price plans for better predictability for both sides. In our experience, volume-based pricing is a legacy headache we want to avoid.
|
||||||
|
|
||||||
If you are FDA-regulated and looking for a modern signing solution, we are happy to discuss your requirements in detail. You can write us (hi@documenso.com) or contact [our enterprise team](https://documen.so/21cfr) at any time or stage.
|
If you are FDA-regulated and looking for a modern signing solution, we are happy to discuss your requirements in detail. You can write us (hi@documenso.com) or contact [our enterprise team](https://documen.so/21cfr) at any time or stage.
|
||||||
@ -70,4 +61,3 @@ If you have any questions or comments, please reach out on [Twitter / X](https:/
|
|||||||
|
|
||||||
Best from Hamburg\
|
Best from Hamburg\
|
||||||
Timur
|
Timur
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/marketing",
|
"name": "@documenso/marketing",
|
||||||
"version": "1.6.1-rc.1",
|
"version": "1.7.0-rc.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -20,7 +20,6 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
"@lingui/macro": "^4.11.1",
|
|
||||||
"@lingui/react": "^4.11.1",
|
"@lingui/react": "^4.11.1",
|
||||||
"@openstatus/react": "^0.0.3",
|
"@openstatus/react": "^0.0.3",
|
||||||
"cmdk": "^0.2.1",
|
"cmdk": "^0.2.1",
|
||||||
|
|||||||
@ -44,6 +44,10 @@ export default async function OSSFriendsPage() {
|
|||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="-mr-[15vw] -mt-[15vh] h-full max-h-[150vh] scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:-mr-[50vw] md:scale-150 lg:scale-[175%]"
|
className="-mr-[15vw] -mt-[15vh] h-full max-h-[150vh] scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:-mr-[50vw] md:scale-150 lg:scale-[175%]"
|
||||||
|
style={{
|
||||||
|
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||||
|
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
@ -46,8 +48,8 @@ export const SinglePlayerClient = () => {
|
|||||||
|
|
||||||
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add document',
|
title: msg`Add document`,
|
||||||
description: 'Upload a document and add fields.',
|
description: msg`Upload a document and add fields.`,
|
||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
onBackStep: uploadedFile
|
onBackStep: uploadedFile
|
||||||
? () => {
|
? () => {
|
||||||
@ -58,8 +60,8 @@ export const SinglePlayerClient = () => {
|
|||||||
onNextStep: () => setStep('sign'),
|
onNextStep: () => setStep('sign'),
|
||||||
},
|
},
|
||||||
sign: {
|
sign: {
|
||||||
title: 'Sign',
|
title: msg`Sign`,
|
||||||
description: 'Enter your details.',
|
description: msg`Enter your details.`,
|
||||||
stepIndex: 2,
|
stepIndex: 2,
|
||||||
onBackStep: () => setStep('fields'),
|
onBackStep: () => setStep('fields'),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -26,6 +26,10 @@ export default function NotFound() {
|
|||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
|
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
|
||||||
|
style={{
|
||||||
|
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||||
|
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||||
|
}}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import type { AutoplayType } from 'embla-carousel-autoplay';
|
import type { AutoplayType } from 'embla-carousel-autoplay';
|
||||||
import Autoplay from 'embla-carousel-autoplay';
|
import Autoplay from 'embla-carousel-autoplay';
|
||||||
import useEmblaCarousel from 'embla-carousel-react';
|
import useEmblaCarousel from 'embla-carousel-react';
|
||||||
|
import { usePlausible } from 'next-plausible';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
import { Card } from '@documenso/ui/primitives/card';
|
import { Card } from '@documenso/ui/primitives/card';
|
||||||
@ -61,6 +64,7 @@ const SLIDES = [
|
|||||||
|
|
||||||
export const Carousel = () => {
|
export const Carousel = () => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
const event = usePlausible();
|
||||||
|
|
||||||
const slides = SLIDES;
|
const slides = SLIDES;
|
||||||
const [_isPlaying, setIsPlaying] = useState(false);
|
const [_isPlaying, setIsPlaying] = useState(false);
|
||||||
@ -238,7 +242,10 @@ export const Carousel = () => {
|
|||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="mx-auto mt-12 w-full max-w-4xl rounded-2xl p-1 before:rounded-2xl" gradient>
|
<Card
|
||||||
|
className="relative mx-auto mt-12 w-full max-w-4xl rounded-2xl p-1 before:rounded-2xl"
|
||||||
|
gradient
|
||||||
|
>
|
||||||
<div className="overflow-hidden rounded-xl" ref={emblaRef}>
|
<div className="overflow-hidden rounded-xl" ref={emblaRef}>
|
||||||
<div className="flex touch-pan-y rounded-xl">
|
<div className="flex touch-pan-y rounded-xl">
|
||||||
{slides.map((slide, index) => (
|
{slides.map((slide, index) => (
|
||||||
@ -269,6 +276,19 @@ export const Carousel = () => {
|
|||||||
</span>
|
</span>
|
||||||
<Progress value={progress} className="h-1" />
|
<Progress value={progress} className="h-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="https://documen.so/book-a-demo"
|
||||||
|
className="bg-foreground/70 dark:bg-foreground/80 absolute inset-0 hidden flex-col items-center justify-center gap-y-2 rounded-xl opacity-0 backdrop-blur-[2px] transition-opacity group-hover:opacity-100 md:flex"
|
||||||
|
onClick={() => event('view-demo')}
|
||||||
|
>
|
||||||
|
<span className="text-background max-w-[60ch] text-2xl font-semibold">Book a Demo</span>
|
||||||
|
<span className="text-background max-w-[60ch] text-center text-sm">
|
||||||
|
Want to learn more about Documenso and how it works? Book a demo today! Our founders
|
||||||
|
will walk you through the application and answer any questions you may have regarding
|
||||||
|
usage, integration, and more.
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="mx-auto mt-6 w-full max-w-4xl px-2 sm:mt-12">
|
<div className="mx-auto mt-6 w-full max-w-4xl px-2 sm:mt-12">
|
||||||
|
|||||||
@ -24,6 +24,10 @@ export const FasterSmarterBeautifulBento = ({
|
|||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
|
style={{
|
||||||
|
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||||
|
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||||
|
|||||||
@ -86,6 +86,10 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
|
style={{
|
||||||
|
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||||
|
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,6 +21,10 @@ export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplat
|
|||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
|
style={{
|
||||||
|
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||||
|
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||||
|
|||||||
@ -25,6 +25,10 @@ export const ShareConnectPaidWidgetBento = ({
|
|||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
|
||||||
|
style={{
|
||||||
|
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||||
|
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">
|
||||||
@ -39,7 +43,7 @@ export const ShareConnectPaidWidgetBento = ({
|
|||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="text-foreground/80 leading-relaxed">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">
|
<strong className="block">
|
||||||
<Trans>Easy Sharing (Soon).</Trans>
|
<Trans>Easy Sharing.</Trans>
|
||||||
</strong>
|
</strong>
|
||||||
<Trans>Receive your personal link to share with everyone you care about.</Trans>
|
<Trans>Receive your personal link to share with everyone you care about.</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ const config: LinguiConfig = {
|
|||||||
locales: APP_I18N_OPTIONS.supportedLangs as unknown as string[],
|
locales: APP_I18N_OPTIONS.supportedLangs as unknown as string[],
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
path: '<rootDir>/../../packages/lib/translations/web/{locale}',
|
path: '<rootDir>/../../packages/lib/translations/{locale}/web',
|
||||||
include: ['<rootDir>/apps/web/src'],
|
include: ['<rootDir>/apps/web/src'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/web",
|
"name": "@documenso/web",
|
||||||
"version": "1.6.1-rc.1",
|
"version": "1.7.0-rc.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -23,7 +23,6 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
"@lingui/macro": "^4.11.1",
|
|
||||||
"@lingui/react": "^4.11.1",
|
"@lingui/react": "^4.11.1",
|
||||||
"@simplewebauthn/browser": "^9.0.1",
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
"@simplewebauthn/server": "^9.0.3",
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
@ -60,9 +59,9 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@documenso/tailwind-config": "*",
|
||||||
"@lingui/loader": "^4.11.1",
|
"@lingui/loader": "^4.11.1",
|
||||||
"@lingui/swc-plugin": "4.0.6",
|
"@lingui/swc-plugin": "4.0.6",
|
||||||
"@documenso/tailwind-config": "*",
|
|
||||||
"@simplewebauthn/types": "^9.0.1",
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { type Document, SigningStatus } from '@documenso/prisma/client';
|
import { type Document, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -22,20 +25,21 @@ export type AdminActionsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
|
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
||||||
trpc.admin.resealDocument.useMutation({
|
trpc.admin.resealDocument.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: _(msg`Success`),
|
||||||
description: 'Document resealed',
|
description: _(msg`Document resealed`),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'Failed to reseal document',
|
description: _(msg`Failed to reseal document`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -54,19 +58,23 @@ export const AdminActions = ({ className, document, recipients }: AdminActionsPr
|
|||||||
)}
|
)}
|
||||||
onClick={() => resealDocument({ id: document.id })}
|
onClick={() => resealDocument({ id: document.id })}
|
||||||
>
|
>
|
||||||
Reseal document
|
<Trans>Reseal document</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent className="max-w-[40ch]">
|
<TooltipContent className="max-w-[40ch]">
|
||||||
|
<Trans>
|
||||||
Attempts sealing the document again, useful for after a code change has occurred to
|
Attempts sealing the document again, useful for after a code change has occurred to
|
||||||
resolve an erroneous document.
|
resolve an erroneous document.
|
||||||
|
</Trans>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href={`/admin/users/${document.userId}`}>Go to owner</Link>
|
<Link href={`/admin/users/${document.userId}`}>
|
||||||
|
<Trans>Go to owner</Trans>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@ -23,6 +25,8 @@ type AdminDocumentDetailsPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
|
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const document = await getEntireDocument({ id: Number(params.id) });
|
const document = await getEntireDocument({ id: Number(params.id) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -35,28 +39,34 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
|
|||||||
|
|
||||||
{document.deletedAt && (
|
{document.deletedAt && (
|
||||||
<Badge size="large" variant="destructive">
|
<Badge size="large" variant="destructive">
|
||||||
Deleted
|
<Trans>Deleted</Trans>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-4 text-sm">
|
<div className="text-muted-foreground mt-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
Created on: <LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
|
<Trans>Created on</Trans>:{' '}
|
||||||
|
<LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Last updated at: <LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
|
<Trans>Last updated at</Trans>:{' '}
|
||||||
|
<LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold">Admin Actions</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
<Trans>Admin Actions</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
|
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
<h2 className="text-lg font-semibold">Recipients</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
<Trans>Recipients</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Accordion type="multiple" className="space-y-4">
|
<Accordion type="multiple" className="space-y-4">
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@ -13,6 +17,7 @@ import {
|
|||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -43,7 +48,9 @@ export type RecipientItemProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const form = useForm<TAdminUpdateRecipientFormSchema>({
|
const form = useForm<TAdminUpdateRecipientFormSchema>({
|
||||||
@ -55,6 +62,50 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
|
|
||||||
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
|
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: 'ID',
|
||||||
|
accessorKey: 'id',
|
||||||
|
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Type`),
|
||||||
|
accessorKey: 'type',
|
||||||
|
cell: ({ row }) => <div>{row.original.type}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Inserted`),
|
||||||
|
accessorKey: 'inserted',
|
||||||
|
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Value`),
|
||||||
|
accessorKey: 'customText',
|
||||||
|
cell: ({ row }) => <div>{row.original.customText}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Signature`),
|
||||||
|
accessorKey: 'signature',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
{row.original.Signature?.typedSignature && (
|
||||||
|
<span>{row.original.Signature.typedSignature}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{row.original.Signature?.signatureImageAsBase64 && (
|
||||||
|
<img
|
||||||
|
src={row.original.Signature.signatureImageAsBase64}
|
||||||
|
alt="Signature"
|
||||||
|
className="h-12 w-full dark:invert"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof recipient)['Field'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
|
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await updateRecipient({
|
await updateRecipient({
|
||||||
@ -64,14 +115,14 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Recipient updated',
|
title: _(msg`Recipient updated`),
|
||||||
description: 'The recipient has been updated successfully',
|
description: _(msg`The recipient has been updated successfully`),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to update recipient',
|
title: _(msg`Failed to update recipient`),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@ -93,7 +144,9 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormLabel required>Name</FormLabel>
|
<FormLabel required>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
@ -109,7 +162,9 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormLabel required>Email</FormLabel>
|
<FormLabel required>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="email" {...field} />
|
<Input type="email" {...field} />
|
||||||
@ -122,7 +177,7 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
Update Recipient
|
<Trans>Update Recipient</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -131,52 +186,11 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
<h2 className="mb-4 text-lg font-semibold">Fields</h2>
|
<h2 className="mb-4 text-lg font-semibold">
|
||||||
|
<Trans>Fields</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<DataTable
|
<DataTable columns={columns} data={recipient.Field} />
|
||||||
data={recipient.Field}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'ID',
|
|
||||||
accessorKey: 'id',
|
|
||||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Type',
|
|
||||||
accessorKey: 'type',
|
|
||||||
cell: ({ row }) => <div>{row.original.type}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Inserted',
|
|
||||||
accessorKey: 'inserted',
|
|
||||||
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Value',
|
|
||||||
accessorKey: 'customText',
|
|
||||||
cell: ({ row }) => <div>{row.original.customText}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Signature',
|
|
||||||
accessorKey: 'signature',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div>
|
|
||||||
{row.original.Signature?.typedSignature && (
|
|
||||||
<span>{row.original.Signature.typedSignature}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{row.original.Signature?.signatureImageAsBase64 && (
|
|
||||||
<img
|
|
||||||
src={row.original.Signature.signatureImageAsBase64}
|
|
||||||
alt="Signature"
|
|
||||||
className="h-12 w-full dark:invert"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import type { Document } from '@documenso/prisma/client';
|
import type { Document } from '@documenso/prisma/client';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -26,7 +29,9 @@ export type SuperDeleteDocumentDialogProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
|
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
@ -43,7 +48,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
|||||||
await deleteDocument({ id: document.id, reason });
|
await deleteDocument({ id: document.id, reason });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document deleted',
|
title: _(msg`Document deleted`),
|
||||||
description: 'The Document has been deleted successfully.',
|
description: 'The Document has been deleted successfully.',
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
@ -52,13 +57,13 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
toast({
|
toast({
|
||||||
title: 'An error occurred',
|
title: _(msg`An error occurred`),
|
||||||
description: err.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: _(msg`An unknown error occurred`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description:
|
description:
|
||||||
err.message ??
|
err.message ??
|
||||||
@ -76,31 +81,41 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<AlertTitle>Delete Document</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Delete Document</Trans>
|
||||||
|
</AlertTitle>
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
|
<Trans>
|
||||||
Delete the document. This action is irreversible so proceed with caution.
|
Delete the document. This action is irreversible so proceed with caution.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">Delete Document</Button>
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete Document</Trans>
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="space-y-4">
|
||||||
<DialogTitle>Delete Document</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Delete Document</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription className="selection:bg-red-100">
|
<AlertDescription className="selection:bg-red-100">
|
||||||
This action is not reversible. Please be certain.
|
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<DialogDescription>To confirm, please enter the reason</DialogDescription>
|
<DialogDescription>
|
||||||
|
<Trans>To confirm, please enter the reason</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
@ -117,7 +132,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={!reason}
|
disabled={!reason}
|
||||||
>
|
>
|
||||||
{isDeletingDocument ? 'Deleting document...' : 'Delete Document'}
|
<Trans>Delete document</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
@ -12,6 +14,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
|||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
@ -23,6 +26,8 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
|||||||
// export type AdminDocumentResultsProps = {};
|
// export type AdminDocumentResultsProps = {};
|
||||||
|
|
||||||
export const AdminDocumentResults = () => {
|
export const AdminDocumentResults = () => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
@ -45,32 +50,22 @@ export const AdminDocumentResults = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPaginationChange = (newPage: number, newPerPage: number) => {
|
const results = findDocumentsData ?? {
|
||||||
updateSearchParams({
|
data: [],
|
||||||
page: newPage,
|
perPage: 20,
|
||||||
perPage: newPerPage,
|
currentPage: 1,
|
||||||
});
|
totalPages: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const columns = useMemo(() => {
|
||||||
<div>
|
return [
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
placeholder="Search by document title"
|
|
||||||
value={term}
|
|
||||||
onChange={(e) => setTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative mt-4">
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{
|
{
|
||||||
header: 'Created',
|
header: _(msg`Created`),
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: _(msg`Title`),
|
||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
@ -84,12 +79,12 @@ export const AdminDocumentResults = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: _(msg`Status`),
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Owner',
|
header: _(msg`Owner`),
|
||||||
accessorKey: 'owner',
|
accessorKey: 'owner',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const avatarFallbackText = row.original.User.name
|
const avatarFallbackText = row.original.User.name
|
||||||
@ -129,11 +124,32 @@ export const AdminDocumentResults = () => {
|
|||||||
accessorKey: 'updatedAt',
|
accessorKey: 'updatedAt',
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
|
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
|
||||||
},
|
},
|
||||||
]}
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
data={findDocumentsData?.data ?? []}
|
}, []);
|
||||||
perPage={findDocumentsData?.perPage ?? 20}
|
|
||||||
currentPage={findDocumentsData?.currentPage ?? 1}
|
const onPaginationChange = (newPage: number, newPerPage: number) => {
|
||||||
totalPages={findDocumentsData?.totalPages ?? 1}
|
updateSearchParams({
|
||||||
|
page: newPage,
|
||||||
|
perPage: newPerPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={_(msg`Search by document title`)}
|
||||||
|
value={term}
|
||||||
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative mt-4">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage ?? 20}
|
||||||
|
currentPage={results.currentPage ?? 1}
|
||||||
|
totalPages={results.totalPages ?? 1}
|
||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { AdminDocumentResults } from './document-results';
|
import { AdminDocumentResults } from './document-results';
|
||||||
|
|
||||||
export default function AdminDocumentsPage() {
|
export default function AdminDocumentsPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Manage documents</h2>
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Manage documents</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<AdminDocumentResults />
|
<AdminDocumentResults />
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
|
|
||||||
@ -12,6 +13,8 @@ export type AdminSectionLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
if (!isAdmin(user)) {
|
if (!isAdmin(user)) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
|
import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -33,7 +34,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
>
|
>
|
||||||
<Link href="/admin/stats">
|
<Link href="/admin/stats">
|
||||||
<BarChart3 className="mr-2 h-5 w-5" />
|
<BarChart3 className="mr-2 h-5 w-5" />
|
||||||
Stats
|
<Trans>Stats</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -47,7 +48,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
>
|
>
|
||||||
<Link href="/admin/users">
|
<Link href="/admin/users">
|
||||||
<Users className="mr-2 h-5 w-5" />
|
<Users className="mr-2 h-5 w-5" />
|
||||||
Users
|
<Trans>Users</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -61,7 +62,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
>
|
>
|
||||||
<Link href="/admin/documents">
|
<Link href="/admin/documents">
|
||||||
<FileStack className="mr-2 h-5 w-5" />
|
<FileStack className="mr-2 h-5 w-5" />
|
||||||
Documents
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -75,7 +76,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
>
|
>
|
||||||
<Link href="/admin/subscriptions">
|
<Link href="/admin/subscriptions">
|
||||||
<Wallet2 className="mr-2 h-5 w-5" />
|
<Wallet2 className="mr-2 h-5 w-5" />
|
||||||
Subscriptions
|
<Trans>Subscriptions</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -89,7 +90,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
>
|
>
|
||||||
<Link href="/admin/site-settings">
|
<Link href="/admin/site-settings">
|
||||||
<Settings className="mr-2 h-5 w-5" />
|
<Settings className="mr-2 h-5 w-5" />
|
||||||
Site Settings
|
<Trans>Site Settings</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
@ -37,8 +39,10 @@ export type BannerFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function BannerForm({ banner }: BannerFormProps) {
|
export function BannerForm({ banner }: BannerFormProps) {
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const form = useForm<TBannerFormSchema>({
|
const form = useForm<TBannerFormSchema>({
|
||||||
resolver: zodResolver(ZBannerFormSchema),
|
resolver: zodResolver(ZBannerFormSchema),
|
||||||
@ -67,8 +71,8 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Banner Updated',
|
title: _(msg`Banner Updated`),
|
||||||
description: 'Your banner has been updated successfully.',
|
description: _(msg`Your banner has been updated successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -76,16 +80,17 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
toast({
|
toast({
|
||||||
title: 'An error occurred',
|
title: _(msg`An error occurred`),
|
||||||
description: err.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: _(msg`An unknown error occurred`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description:
|
description: _(
|
||||||
'We encountered an unknown error while attempting to update the banner. Please try again later.',
|
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,10 +98,14 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">Site Banner</h2>
|
<h2 className="font-semibold">
|
||||||
|
<Trans>Site Banner</Trans>
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
The site banner is a message that is shown at the top of the site. It can be used to display
|
<Trans>
|
||||||
important information to your users.
|
The site banner is a message that is shown at the top of the site. It can be used to
|
||||||
|
display important information to your users.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@ -110,7 +119,9 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
name="enabled"
|
name="enabled"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormLabel>Enabled</FormLabel>
|
<FormLabel>
|
||||||
|
<Trans>Enabled</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div>
|
<div>
|
||||||
@ -131,7 +142,9 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
name="data.bgColor"
|
name="data.bgColor"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Background Color</FormLabel>
|
<FormLabel>
|
||||||
|
<Trans>Background Color</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div>
|
<div>
|
||||||
@ -149,7 +162,9 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
name="data.textColor"
|
name="data.textColor"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Text Color</FormLabel>
|
<FormLabel>
|
||||||
|
<Trans>Text Color</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div>
|
<div>
|
||||||
@ -170,14 +185,16 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
name="data.content"
|
name="data.content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>
|
||||||
|
<Trans>Content</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea className="h-32 resize-none" {...field} />
|
<Textarea className="h-32 resize-none" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The content to show in the banner, HTML is allowed
|
<Trans>The content to show in the banner, HTML is allowed</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -191,7 +208,7 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
loading={isUpdateSiteSettingLoading}
|
loading={isUpdateSiteSettingLoading}
|
||||||
className="mt-4 justify-end self-end"
|
className="mt-4 justify-end self-end"
|
||||||
>
|
>
|
||||||
Update Banner
|
<Trans>Update Banner</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||||
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||||
|
|
||||||
@ -8,13 +12,20 @@ import { BannerForm } from './banner-form';
|
|||||||
// import { BannerForm } from './banner-form';
|
// import { BannerForm } from './banner-form';
|
||||||
|
|
||||||
export default async function AdminBannerPage() {
|
export default async function AdminBannerPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const banner = await getSiteSettings().then((settings) =>
|
const banner = await getSiteSettings().then((settings) =>
|
||||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title="Site Settings" subtitle="Manage your site settings here" />
|
<SettingsHeader
|
||||||
|
title={_(msg`Site Settings`)}
|
||||||
|
subtitle={_(msg`Manage your site settings here`)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<BannerForm banner={banner} />
|
<BannerForm banner={banner} />
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import {
|
import {
|
||||||
File,
|
File,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
@ -12,6 +14,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||||
import {
|
import {
|
||||||
@ -27,6 +30,10 @@ import { SignerConversionChart } from './signer-conversion-chart';
|
|||||||
import { UserWithDocumentChart } from './user-with-document';
|
import { UserWithDocumentChart } from './user-with-document';
|
||||||
|
|
||||||
export default async function AdminStatsPage() {
|
export default async function AdminStatsPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [
|
const [
|
||||||
usersCount,
|
usersCount,
|
||||||
usersWithSubscriptionsCount,
|
usersWithSubscriptionsCount,
|
||||||
@ -49,64 +56,98 @@ export default async function AdminStatsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Instance Stats</h2>
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Instance Stats</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<CardMetric icon={Users} title="Total Users" value={usersCount} />
|
<CardMetric icon={Users} title={_(msg`Total Users`)} value={usersCount} />
|
||||||
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
<CardMetric icon={File} title={_(msg`Total Documents`)} value={docStats.ALL} />
|
||||||
<CardMetric
|
<CardMetric
|
||||||
icon={UserPlus}
|
icon={UserPlus}
|
||||||
title="Active Subscriptions"
|
title={_(msg`Active Subscriptions`)}
|
||||||
value={usersWithSubscriptionsCount}
|
value={usersWithSubscriptionsCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardMetric icon={FileCog} title="App Version" value={`v${process.env.APP_VERSION}`} />
|
<CardMetric
|
||||||
|
icon={FileCog}
|
||||||
|
title={_(msg`App Version`)}
|
||||||
|
value={`v${process.env.APP_VERSION}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 gap-8">
|
<div className="mt-16 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-3xl font-semibold">Document metrics</h3>
|
<h3 className="text-3xl font-semibold">
|
||||||
|
<Trans>Document metrics</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
|
<CardMetric icon={FileEdit} title={_(msg`Drafted Documents`)} value={docStats.DRAFT} />
|
||||||
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
|
<CardMetric
|
||||||
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
|
icon={FileClock}
|
||||||
|
title={_(msg`Pending Documents`)}
|
||||||
|
value={docStats.PENDING}
|
||||||
|
/>
|
||||||
|
<CardMetric
|
||||||
|
icon={FileCheck}
|
||||||
|
title={_(msg`Completed Documents`)}
|
||||||
|
value={docStats.COMPLETED}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
|
<h3 className="text-3xl font-semibold">
|
||||||
|
<Trans>Recipients metrics</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<CardMetric
|
<CardMetric
|
||||||
icon={UserSquare2}
|
icon={UserSquare2}
|
||||||
title="Total Recipients"
|
title={_(msg`Total Recipients`)}
|
||||||
value={recipientStats.TOTAL_RECIPIENTS}
|
value={recipientStats.TOTAL_RECIPIENTS}
|
||||||
/>
|
/>
|
||||||
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
|
<CardMetric
|
||||||
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
|
icon={Mail}
|
||||||
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
|
title={_(msg`Documents Received`)}
|
||||||
|
value={recipientStats.SENT}
|
||||||
|
/>
|
||||||
|
<CardMetric
|
||||||
|
icon={MailOpen}
|
||||||
|
title={_(msg`Documents Viewed`)}
|
||||||
|
value={recipientStats.OPENED}
|
||||||
|
/>
|
||||||
|
<CardMetric
|
||||||
|
icon={PenTool}
|
||||||
|
title={_(msg`Signatures Collected`)}
|
||||||
|
value={recipientStats.SIGNED}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16">
|
<div className="mt-16">
|
||||||
<h3 className="text-3xl font-semibold">Charts</h3>
|
<h3 className="text-3xl font-semibold">
|
||||||
|
<Trans>Charts</Trans>
|
||||||
|
</h3>
|
||||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||||
<UserWithDocumentChart
|
<UserWithDocumentChart
|
||||||
data={MONTHLY_USERS_SIGNED}
|
data={MONTHLY_USERS_SIGNED}
|
||||||
title="MAU (created document)"
|
title={_(msg`MAU (created document)`)}
|
||||||
tooltip="Monthly Active Users: Users that created at least one Document"
|
tooltip={_(msg`Monthly Active Users: Users that created at least one Document`)}
|
||||||
/>
|
/>
|
||||||
<UserWithDocumentChart
|
<UserWithDocumentChart
|
||||||
data={MONTHLY_USERS_SIGNED}
|
data={MONTHLY_USERS_SIGNED}
|
||||||
completed
|
completed
|
||||||
title="MAU (had document completed)"
|
title={_(msg`MAU (had document completed)`)}
|
||||||
tooltip="Monthly Active Users: Users that had at least one of their documents completed"
|
tooltip={_(
|
||||||
|
msg`Monthly Active Users: Users that had at least one of their documents completed`,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<SignerConversionChart title="Signers that Signed Up" data={signerConversionMonthly} />
|
<SignerConversionChart title="Signers that Signed Up" data={signerConversionMonthly} />
|
||||||
<SignerConversionChart
|
<SignerConversionChart
|
||||||
title="Total Signers that Signed Up"
|
title={_(msg`Total Signers that Signed Up`)}
|
||||||
data={signerConversionMonthly}
|
data={signerConversionMonthly}
|
||||||
cummulative
|
cummulative
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
|
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -11,20 +14,32 @@ import {
|
|||||||
} from '@documenso/ui/primitives/table';
|
} from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
export default async function Subscriptions() {
|
export default async function Subscriptions() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const subscriptions = await findSubscriptions();
|
const subscriptions = await findSubscriptions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Manage subscriptions</h2>
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Manage subscriptions</Trans>
|
||||||
|
</h2>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead>
|
<TableHead>ID</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>
|
||||||
<TableHead>Created At</TableHead>
|
<Trans>Status</Trans>
|
||||||
<TableHead>Ends On</TableHead>
|
</TableHead>
|
||||||
<TableHead>User ID</TableHead>
|
<TableHead>
|
||||||
|
<Trans>Created At</Trans>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Trans>Ends On</Trans>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Trans>User ID</Trans>
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -27,8 +30,10 @@ export type DeleteUserDialogProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
|
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
@ -43,8 +48,8 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Account deleted',
|
title: _(msg`Account deleted`),
|
||||||
description: 'The account has been deleted successfully.',
|
description: _(msg`The account has been deleted successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -52,17 +57,19 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
toast({
|
toast({
|
||||||
title: 'An error occurred',
|
title: _(msg`An error occurred`),
|
||||||
description: err.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: _(msg`An unknown error occurred`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description:
|
description:
|
||||||
err.message ??
|
err.message ??
|
||||||
'We encountered an unknown error while attempting to delete your account. Please try again later.',
|
_(
|
||||||
|
msg`We encountered an unknown error while attempting to delete your account. Please try again later.`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -77,31 +84,39 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
|||||||
<div>
|
<div>
|
||||||
<AlertTitle>Delete Account</AlertTitle>
|
<AlertTitle>Delete Account</AlertTitle>
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
|
<Trans>
|
||||||
Delete the users account and all its contents. This action is irreversible and will
|
Delete the users account and all its contents. This action is irreversible and will
|
||||||
cancel their subscription, so proceed with caution.
|
cancel their subscription, so proceed with caution.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">Delete Account</Button>
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete Account</Trans>
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="space-y-4">
|
||||||
<DialogTitle>Delete Account</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Delete Account</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription className="selection:bg-red-100">
|
<AlertDescription className="selection:bg-red-100">
|
||||||
This action is not reversible. Please be certain.
|
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
To confirm, please enter the accounts email address <br />({user.email}).
|
To confirm, please enter the accounts email address <br />({user.email}).
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@ -119,7 +134,7 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={email !== user.email}
|
disabled={email !== user.email}
|
||||||
>
|
>
|
||||||
{isDeletingUser ? 'Deleting account...' : 'Delete Account'}
|
<Trans>Delete account</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
|
||||||
import { Role } from '@documenso/prisma/client';
|
import { Role } from '@documenso/prisma/client';
|
||||||
@ -59,7 +60,9 @@ const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
|||||||
<PopoverContent className="w-[200px] p-0">
|
<PopoverContent className="w-[200px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={selectedValues.join(', ')} />
|
<CommandInput placeholder={selectedValues.join(', ')} />
|
||||||
<CommandEmpty>No value found.</CommandEmpty>
|
<CommandEmpty>
|
||||||
|
<Trans>No value found.</Trans>
|
||||||
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{allRoles.map((value: string, i: number) => (
|
{allRoles.map((value: string, i: number) => (
|
||||||
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
@ -28,7 +30,9 @@ const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
|
|||||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||||
|
|
||||||
export default function UserPage({ params }: { params: { id: number } }) {
|
export default function UserPage({ params }: { params: { id: number } }) {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { data: user } = trpc.profile.getUser.useQuery(
|
const { data: user } = trpc.profile.getUser.useQuery(
|
||||||
@ -65,14 +69,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Profile updated',
|
title: _(msg`Profile updated`),
|
||||||
description: 'Your profile has been updated.',
|
description: _(msg`Your profile has been updated.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while updating your profile.',
|
description: _(msg`An error occurred while updating your profile.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -80,7 +84,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Manage {user?.name}'s profile</h2>
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Manage {user?.name}'s profile</Trans>
|
||||||
|
</h2>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
|
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
|
||||||
@ -89,7 +95,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Name</FormLabel>
|
<FormLabel className="text-muted-foreground">
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" {...field} value={field.value ?? ''} />
|
<Input type="text" {...field} value={field.value ?? ''} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -102,7 +110,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Email</FormLabel>
|
<FormLabel className="text-muted-foreground">
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" {...field} />
|
<Input type="text" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -117,7 +127,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
render={({ field: { onChange } }) => (
|
render={({ field: { onChange } }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
<FormLabel className="text-muted-foreground">
|
||||||
|
<Trans>Roles</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<MultiSelectRoleCombobox
|
<MultiSelectRoleCombobox
|
||||||
listValues={roles}
|
listValues={roles}
|
||||||
@ -132,7 +144,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
Update user
|
<Trans>Update user</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useTransition } from 'react';
|
import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Edit, Loader } from 'lucide-react';
|
import { Edit, Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { Document, Role, Subscription } from '@documenso/prisma/client';
|
import type { Document, Role, Subscription } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
@ -45,11 +48,70 @@ export const UsersDataTable = ({
|
|||||||
page,
|
page,
|
||||||
individualPriceIds,
|
individualPriceIds,
|
||||||
}: UsersDataTableProps) => {
|
}: UsersDataTableProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
const [searchString, setSearchString] = useState('');
|
const [searchString, setSearchString] = useState('');
|
||||||
const debouncedSearchString = useDebouncedValue(searchString, 1000);
|
const debouncedSearchString = useDebouncedValue(searchString, 1000);
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: 'ID',
|
||||||
|
accessorKey: 'id',
|
||||||
|
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Name`),
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => <div>{row.original.name}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Email`),
|
||||||
|
accessorKey: 'email',
|
||||||
|
cell: ({ row }) => <div>{row.original.email}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Roles`),
|
||||||
|
accessorKey: 'roles',
|
||||||
|
cell: ({ row }) => row.original.roles.join(', '),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Subscription`),
|
||||||
|
accessorKey: 'subscription',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
|
||||||
|
individualPriceIds.includes(sub.priceId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return foundIndividualSubscription?.status ?? 'NONE';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Documents`),
|
||||||
|
accessorKey: 'documents',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return <div>{row.original.Document.length}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '',
|
||||||
|
accessorKey: 'edit',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Button className="w-24" asChild>
|
||||||
|
<Link href={`/admin/users/${row.original.id}`}>
|
||||||
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof users)[number]>[];
|
||||||
|
}, [individualPriceIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
updateSearchParams({
|
updateSearchParams({
|
||||||
@ -79,65 +141,12 @@ export const UsersDataTable = ({
|
|||||||
<Input
|
<Input
|
||||||
className="my-6 flex flex-row gap-4"
|
className="my-6 flex flex-row gap-4"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name or email"
|
placeholder={_(msg`Search by name or email`)}
|
||||||
value={searchString}
|
value={searchString}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={columns}
|
||||||
{
|
|
||||||
header: 'ID',
|
|
||||||
accessorKey: 'id',
|
|
||||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Name',
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ row }) => <div>{row.original.name}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Email',
|
|
||||||
accessorKey: 'email',
|
|
||||||
cell: ({ row }) => <div>{row.original.email}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Roles',
|
|
||||||
accessorKey: 'roles',
|
|
||||||
cell: ({ row }) => row.original.roles.join(', '),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Subscription',
|
|
||||||
accessorKey: 'subscription',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
|
|
||||||
individualPriceIds.includes(sub.priceId),
|
|
||||||
);
|
|
||||||
|
|
||||||
return foundIndividualSubscription?.status ?? 'NONE';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Documents',
|
|
||||||
accessorKey: 'documents',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return <div>{row.original.Document.length}</div>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: '',
|
|
||||||
accessorKey: 'edit',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return (
|
|
||||||
<Button className="w-24" asChild>
|
|
||||||
<Link href={`/admin/users/${row.original.id}`}>
|
|
||||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={users}
|
data={users}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
|
||||||
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
|
|
||||||
import { UsersDataTable } from './data-table-users';
|
import { UsersDataTable } from './data-table-users';
|
||||||
@ -13,6 +16,8 @@ type AdminManageUsersProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
|
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const page = Number(searchParams.page) || 1;
|
const page = Number(searchParams.page) || 1;
|
||||||
const perPage = Number(searchParams.perPage) || 10;
|
const perPage = Number(searchParams.perPage) || 10;
|
||||||
const searchString = searchParams.search || '';
|
const searchString = searchParams.search || '';
|
||||||
@ -26,7 +31,10 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Manage users</h2>
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Manage users</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<UsersDataTable
|
<UsersDataTable
|
||||||
users={users}
|
users={users}
|
||||||
individualPriceIds={individualPriceIds}
|
individualPriceIds={individualPriceIds}
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -26,6 +28,7 @@ export type DocumentPageViewButtonProps = {
|
|||||||
export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
|
export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
@ -57,8 +60,8 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
|
|||||||
await downloadPDF({ documentData, fileName: documentWithData.title });
|
await downloadPDF({ documentData, fileName: documentWithData.title });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'An error occurred while downloading your document.',
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -77,19 +80,19 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
|
|||||||
.with(RecipientRole.SIGNER, () => (
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<>
|
<>
|
||||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Sign
|
<Trans>Sign</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.APPROVER, () => (
|
.with(RecipientRole.APPROVER, () => (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Approve
|
<Trans>Approve</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.otherwise(() => (
|
||||||
<>
|
<>
|
||||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
View
|
<Trans>View</Trans>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</Link>
|
</Link>
|
||||||
@ -97,13 +100,15 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
|
|||||||
))
|
))
|
||||||
.with({ isComplete: false }, () => (
|
.with({ isComplete: false }, () => (
|
||||||
<Button className="w-full" asChild>
|
<Button className="w-full" asChild>
|
||||||
<Link href={`${documentsPath}/${document.id}/edit`}>Edit</Link>
|
<Link href={`${documentsPath}/${document.id}/edit`}>
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-full" onClick={onDownloadClick}>
|
<Button className="w-full" onClick={onDownloadClick}>
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
Download
|
<Trans>Download</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.otherwise(() => null);
|
.otherwise(() => null);
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
@ -47,6 +49,7 @@ export type DocumentPageViewDropdownProps = {
|
|||||||
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
@ -82,8 +85,8 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
await downloadPDF({ documentData, fileName: document.title });
|
await downloadPDF({ documentData, fileName: document.title });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'An error occurred while downloading your document.',
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -98,13 +101,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="end" forceMount>
|
<DropdownMenuContent className="w-52" align="end" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Action</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`${documentsPath}/${document.id}/edit`}>
|
<Link href={`${documentsPath}/${document.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
@ -112,20 +117,20 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
{isComplete && (
|
{isComplete && (
|
||||||
<DropdownMenuItem onClick={onDownloadClick}>
|
<DropdownMenuItem onClick={onDownloadClick}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Download
|
<Trans>Download</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`${documentsPath}/${document.id}/logs`}>
|
<Link href={`${documentsPath}/${document.id}/logs`}>
|
||||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||||
Audit Log
|
<Trans>Audit Log</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Duplicate
|
<Trans>Duplicate</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@ -133,10 +138,12 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
|
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
<Trans>Delete</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Share</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<ResendDocumentActionItem
|
<ResendDocumentActionItem
|
||||||
document={document}
|
document={document}
|
||||||
@ -151,7 +158,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
||||||
Share Signing Card
|
<Trans>Share Signing Card</Trans>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
@ -23,6 +25,7 @@ export const DocumentPageViewInformation = ({
|
|||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const documentInformation = useMemo(() => {
|
const documentInformation = useMemo(() => {
|
||||||
let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy');
|
let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy');
|
||||||
@ -38,31 +41,34 @@ export const DocumentPageViewInformation = ({
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
description: 'Uploaded by',
|
description: msg`Uploaded by`,
|
||||||
value: userId === document.userId ? 'You' : document.User.name ?? document.User.email,
|
value: userId === document.userId ? _(msg`You`) : document.User.name ?? document.User.email,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Created',
|
description: msg`Created`,
|
||||||
value: createdValue,
|
value: createdValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Last modified',
|
description: msg`Last modified`,
|
||||||
value: lastModifiedValue,
|
value: lastModifiedValue,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isMounted, document, locale, userId]);
|
}, [isMounted, document, locale, userId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
||||||
<h1 className="px-4 py-3 font-medium">Information</h1>
|
<h1 className="px-4 py-3 font-medium">
|
||||||
|
<Trans>Information</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<ul className="divide-y border-t">
|
<ul className="divide-y border-t">
|
||||||
{documentInformation.map((item) => (
|
{documentInformation.map((item, i) => (
|
||||||
<li
|
<li
|
||||||
key={item.description}
|
key={i}
|
||||||
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
|
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground">{item.description}</span>
|
<span className="text-muted-foreground">{_(item.description)}</span>
|
||||||
<span>{item.value}</span>
|
<span>{item.value}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
|
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -21,6 +23,8 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
documentId,
|
documentId,
|
||||||
userId,
|
userId,
|
||||||
}: DocumentPageViewRecentActivityProps) => {
|
}: DocumentPageViewRecentActivityProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -49,7 +53,9 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
return (
|
return (
|
||||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||||
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
|
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||||
<h1 className="text-foreground font-medium">Recent activity</h1>
|
<h1 className="text-foreground font-medium">
|
||||||
|
<Trans>Recent activity</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
{/* Can add dropdown menu here for additional options. */}
|
{/* Can add dropdown menu here for additional options. */}
|
||||||
</div>
|
</div>
|
||||||
@ -62,12 +68,14 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
|
|
||||||
{isLoadingError && (
|
{isLoadingError && (
|
||||||
<div className="flex h-full flex-col items-center justify-center py-16">
|
<div className="flex h-full flex-col items-center justify-center py-16">
|
||||||
<p className="text-foreground/80 text-sm">Unable to load document history</p>
|
<p className="text-foreground/80 text-sm">
|
||||||
|
<Trans>Unable to load document history</Trans>
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={async () => refetch()}
|
onClick={async () => refetch()}
|
||||||
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
||||||
>
|
>
|
||||||
Click here to retry
|
<Trans>Click here to retry</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -89,14 +97,16 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
onClick={async () => fetchNextPage()}
|
onClick={async () => fetchNextPage()}
|
||||||
className="text-foreground/70 hover:text-muted-foreground text-xs"
|
className="text-foreground/70 hover:text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
{isFetchingNextPage ? 'Loading...' : 'Load older activity'}
|
{isFetchingNextPage ? _(msg`Loading...`) : _(msg`Load older activity`)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{documentAuditLogs.length === 0 && (
|
{documentAuditLogs.length === 0 && (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
<p className="text-muted-foreground/70 text-sm">No recent activity</p>
|
<p className="text-muted-foreground/70 text-sm">
|
||||||
|
<Trans>No recent activity</Trans>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -133,6 +143,7 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Todo: Translations. */}
|
||||||
<p
|
<p
|
||||||
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
||||||
title={`${formatDocumentAuditLogAction(auditLog, userId).prefix} ${
|
title={`${formatDocumentAuditLogAction(auditLog, userId).prefix} ${
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
|
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -21,17 +23,21 @@ export const DocumentPageViewRecipients = ({
|
|||||||
document,
|
document,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
}: DocumentPageViewRecipientsProps) => {
|
}: DocumentPageViewRecipientsProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const recipients = document.Recipient;
|
const recipients = document.Recipient;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||||
<h1 className="text-foreground font-medium">Recipients</h1>
|
<h1 className="text-foreground font-medium">
|
||||||
|
<Trans>Recipients</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
{document.status !== DocumentStatus.COMPLETED && (
|
||||||
<Link
|
<Link
|
||||||
href={`${documentRootPath}/${document.id}/edit?step=signers`}
|
href={`${documentRootPath}/${document.id}/edit?step=signers`}
|
||||||
title="Modify recipients"
|
title={_(msg`Modify recipients`)}
|
||||||
className="flex flex-row items-center justify-between"
|
className="flex flex-row items-center justify-between"
|
||||||
>
|
>
|
||||||
{recipients.length === 0 ? (
|
{recipients.length === 0 ? (
|
||||||
@ -45,7 +51,9 @@ export const DocumentPageViewRecipients = ({
|
|||||||
|
|
||||||
<ul className="text-muted-foreground divide-y border-t">
|
<ul className="text-muted-foreground divide-y border-t">
|
||||||
{recipients.length === 0 && (
|
{recipients.length === 0 && (
|
||||||
<li className="flex flex-col items-center justify-center py-6 text-sm">No recipients</li>
|
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||||
|
<Trans>No recipients</Trans>
|
||||||
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipients.map((recipient) => (
|
{recipients.map((recipient) => (
|
||||||
@ -55,7 +63,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||||
secondaryText={
|
secondaryText={
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -67,19 +75,19 @@ export const DocumentPageViewRecipients = ({
|
|||||||
.with(RecipientRole.APPROVER, () => (
|
.with(RecipientRole.APPROVER, () => (
|
||||||
<>
|
<>
|
||||||
<CheckIcon className="mr-1 h-3 w-3" />
|
<CheckIcon className="mr-1 h-3 w-3" />
|
||||||
Approved
|
<Trans>Approved</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.CC, () =>
|
.with(RecipientRole.CC, () =>
|
||||||
document.status === DocumentStatus.COMPLETED ? (
|
document.status === DocumentStatus.COMPLETED ? (
|
||||||
<>
|
<>
|
||||||
<MailIcon className="mr-1 h-3 w-3" />
|
<MailIcon className="mr-1 h-3 w-3" />
|
||||||
Sent
|
<Trans>Sent</Trans>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckIcon className="mr-1 h-3 w-3" />
|
<CheckIcon className="mr-1 h-3 w-3" />
|
||||||
Ready
|
<Trans>Ready</Trans>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -87,13 +95,13 @@ export const DocumentPageViewRecipients = ({
|
|||||||
.with(RecipientRole.SIGNER, () => (
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<>
|
<>
|
||||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||||
Signed
|
<Trans>Signed</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.VIEWER, () => (
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<>
|
<>
|
||||||
<MailOpenIcon className="mr-1 h-3 w-3" />
|
<MailOpenIcon className="mr-1 h-3 w-3" />
|
||||||
Viewed
|
<Trans>Viewed</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
@ -104,7 +112,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
Pending
|
<Trans>Pending</Trans>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Plural, Trans } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -42,6 +44,7 @@ export type DocumentPageViewProps = {
|
|||||||
|
|
||||||
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const documentId = Number(id);
|
const documentId = Number(id);
|
||||||
|
|
||||||
@ -107,7 +110,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-row justify-between truncate">
|
<div className="flex flex-row justify-between truncate">
|
||||||
@ -132,12 +135,18 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
documentStatus={document.status}
|
documentStatus={document.status}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
>
|
>
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>
|
||||||
|
<Trans>{recipients.length} Recipient(s)</Trans>
|
||||||
|
</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
|
{document.deletedAt && (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
<Trans>Document deleted</Trans>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -146,7 +155,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<Clock9 className="mr-1.5 h-4 w-4" />
|
<Clock9 className="mr-1.5 h-4 w-4" />
|
||||||
Document history
|
<Trans>Document history</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DocumentHistorySheet>
|
</DocumentHistorySheet>
|
||||||
</div>
|
</div>
|
||||||
@ -172,7 +181,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
<div className="flex flex-row items-center justify-between px-4">
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
|
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
|
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
|
||||||
@ -180,22 +189,24 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
|
|
||||||
<p className="text-muted-foreground mt-2 px-4 text-sm ">
|
<p className="text-muted-foreground mt-2 px-4 text-sm ">
|
||||||
{match(document.status)
|
{match(document.status)
|
||||||
.with(
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
DocumentStatus.COMPLETED,
|
<Trans>This document has been signed by all recipients</Trans>
|
||||||
() => 'This document has been signed by all recipients',
|
))
|
||||||
)
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
.with(
|
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||||
DocumentStatus.DRAFT,
|
))
|
||||||
() => 'This document is currently a draft and has not been sent',
|
|
||||||
)
|
|
||||||
.with(DocumentStatus.PENDING, () => {
|
.with(DocumentStatus.PENDING, () => {
|
||||||
const pendingRecipients = recipients.filter(
|
const pendingRecipients = recipients.filter(
|
||||||
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
||||||
);
|
);
|
||||||
|
|
||||||
return `Waiting on ${pendingRecipients.length} recipient${
|
return (
|
||||||
pendingRecipients.length > 1 ? 's' : ''
|
<Plural
|
||||||
}`;
|
value={pendingRecipients.length}
|
||||||
|
one="Waiting on 1 recipient"
|
||||||
|
other="Waiting on # recipients"
|
||||||
|
/>
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
SKIP_QUERY_BATCH_META,
|
SKIP_QUERY_BATCH_META,
|
||||||
@ -45,6 +48,7 @@ export const EditDocumentForm = ({
|
|||||||
isDocumentEnterprise,
|
isDocumentEnterprise,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@ -125,23 +129,23 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
settings: {
|
settings: {
|
||||||
title: 'General',
|
title: msg`General`,
|
||||||
description: 'Configure general settings for the document.',
|
description: msg`Configure general settings for the document.`,
|
||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
},
|
},
|
||||||
signers: {
|
signers: {
|
||||||
title: 'Add Signers',
|
title: msg`Add Signers`,
|
||||||
description: 'Add the people who will sign the document.',
|
description: msg`Add the people who will sign the document.`,
|
||||||
stepIndex: 2,
|
stepIndex: 2,
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add Fields',
|
title: msg`Add Fields`,
|
||||||
description: 'Add all relevant fields for each recipient.',
|
description: msg`Add all relevant fields for each recipient.`,
|
||||||
stepIndex: 3,
|
stepIndex: 3,
|
||||||
},
|
},
|
||||||
subject: {
|
subject: {
|
||||||
title: 'Add Subject',
|
title: msg`Add Subject`,
|
||||||
description: 'Add the subject and message you wish to send to signers.',
|
description: msg`Add the subject and message you wish to send to signers.`,
|
||||||
stepIndex: 4,
|
stepIndex: 4,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -191,8 +195,8 @@ export const EditDocumentForm = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while updating the document settings.',
|
description: _(msg`An error occurred while updating the document settings.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -218,8 +222,8 @@ export const EditDocumentForm = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while adding signers.',
|
description: _(msg`An error occurred while adding signers.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -248,8 +252,8 @@ export const EditDocumentForm = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while adding the fields.',
|
description: _(msg`An error occurred while adding the fields.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -269,8 +273,8 @@ export const EditDocumentForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document sent',
|
title: _(msg`Document sent`),
|
||||||
description: 'Your document has been sent successfully.',
|
description: _(msg`Your document has been sent successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -279,8 +283,8 @@ export const EditDocumentForm = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while sending the document.',
|
description: _(msg`An error occurred while sending the document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Plural, Trans } from '@lingui/macro';
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
@ -78,7 +79,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
@ -97,7 +98,9 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
documentStatus={document.status}
|
documentStatus={document.status}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
>
|
>
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>
|
||||||
|
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
||||||
|
</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { DocumentEditPageView } from './document-edit-page-view';
|
import { DocumentEditPageView } from './document-edit-page-view';
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
@ -7,5 +9,7 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentEditPage({ params }: DocumentPageProps) {
|
export default function DocumentEditPage({ params }: DocumentPageProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return <DocumentEditPageView params={params} />;
|
return <DocumentEditPageView params={params} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,23 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { ChevronLeft, Loader } from 'lucide-react';
|
import { ChevronLeft, Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
<Trans>Loading Document...</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex h-10 items-center">
|
<div className="flex h-10 items-center">
|
||||||
@ -25,7 +29,9 @@ export default function Loading() {
|
|||||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
<p className="text-muted-foreground mt-4">
|
||||||
|
<Trans>Loading document...</Trans>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
@ -10,6 +14,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
|||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
@ -27,7 +32,7 @@ const dateFormat: DateTimeFormatOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
|
export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
|
||||||
const parser = new UAParser();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
@ -66,16 +71,17 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
|||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const columns = useMemo(() => {
|
||||||
<DataTable
|
const parser = new UAParser();
|
||||||
columns={[
|
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
header: 'Time',
|
header: _(msg`Time`),
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'User',
|
header: _(msg`User`),
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.name || row.original.email ? (
|
row.original.name || row.original.email ? (
|
||||||
@ -97,12 +103,10 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: _(msg`Action`),
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>
|
<span>{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}</span>
|
||||||
{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -123,7 +127,12 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
|||||||
return result.browser.name ?? 'N/A';
|
return result.browser.name ?? 'N/A';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
data={results.data}
|
data={results.data}
|
||||||
perPage={results.perPage}
|
perPage={results.perPage}
|
||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
@ -29,6 +32,8 @@ export type DocumentLogsPageViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const locale = getLocale();
|
const locale = getLocale();
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
@ -60,39 +65,39 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
|||||||
redirect(documentRootPath);
|
redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentInformation: { description: string; value: string }[] = [
|
const documentInformation: { description: MessageDescriptor; value: string }[] = [
|
||||||
{
|
{
|
||||||
description: 'Document title',
|
description: msg`Document title`,
|
||||||
value: document.title,
|
value: document.title,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Document ID',
|
description: msg`Document ID`,
|
||||||
value: document.id.toString(),
|
value: document.id.toString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Document status',
|
description: msg`Document status`,
|
||||||
value: FRIENDLY_STATUS_MAP[document.status].label,
|
value: _(FRIENDLY_STATUS_MAP[document.status].label),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Created by',
|
description: msg`Created by`,
|
||||||
value: document.User.name
|
value: document.User.name
|
||||||
? `${document.User.name} (${document.User.email})`
|
? `${document.User.name} (${document.User.email})`
|
||||||
: document.User.email,
|
: document.User.email,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Date created',
|
description: msg`Date created`,
|
||||||
value: DateTime.fromJSDate(document.createdAt)
|
value: DateTime.fromJSDate(document.createdAt)
|
||||||
.setLocale(locale)
|
.setLocale(locale)
|
||||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Last updated',
|
description: msg`Last updated`,
|
||||||
value: DateTime.fromJSDate(document.updatedAt)
|
value: DateTime.fromJSDate(document.updatedAt)
|
||||||
.setLocale(locale)
|
.setLocale(locale)
|
||||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Time zone',
|
description: msg`Time zone`,
|
||||||
value: document.documentMeta?.timezone ?? 'N/A',
|
value: document.documentMeta?.timezone ?? 'N/A',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -114,7 +119,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
|||||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Document
|
<Trans>Document</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-col justify-between truncate sm:flex-row">
|
<div className="flex flex-col justify-between truncate sm:flex-row">
|
||||||
@ -147,7 +152,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
|||||||
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||||
{documentInformation.map((info, i) => (
|
{documentInformation.map((info, i) => (
|
||||||
<div className="text-foreground text-sm" key={i}>
|
<div className="text-foreground text-sm" key={i}>
|
||||||
<h3 className="font-semibold">{info.description}</h3>
|
<h3 className="font-semibold">{_(info.description)}</h3>
|
||||||
<p className="text-muted-foreground">{info.value}</p>
|
<p className="text-muted-foreground">{info.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -19,6 +21,7 @@ export const DownloadAuditLogButton = ({
|
|||||||
documentId,
|
documentId,
|
||||||
}: DownloadAuditLogButtonProps) => {
|
}: DownloadAuditLogButtonProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { mutateAsync: downloadAuditLogs, isLoading } =
|
const { mutateAsync: downloadAuditLogs, isLoading } =
|
||||||
trpc.document.downloadAuditLogs.useMutation();
|
trpc.document.downloadAuditLogs.useMutation();
|
||||||
@ -59,8 +62,10 @@ export const DownloadAuditLogButton = ({
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'Sorry, we were unable to download the audit logs. Please try again later.',
|
description: _(
|
||||||
|
msg`Sorry, we were unable to download the audit logs. Please try again later.`,
|
||||||
|
),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -73,7 +78,7 @@ export const DownloadAuditLogButton = ({
|
|||||||
onClick={() => void onDownloadAuditLogsClick()}
|
onClick={() => void onDownloadAuditLogsClick()}
|
||||||
>
|
>
|
||||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||||
Download Audit Logs
|
<Trans>Download Audit Logs</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
@ -20,6 +22,7 @@ export const DownloadCertificateButton = ({
|
|||||||
documentStatus,
|
documentStatus,
|
||||||
}: DownloadCertificateButtonProps) => {
|
}: DownloadCertificateButtonProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { mutateAsync: downloadCertificate, isLoading } =
|
const { mutateAsync: downloadCertificate, isLoading } =
|
||||||
trpc.document.downloadCertificate.useMutation();
|
trpc.document.downloadCertificate.useMutation();
|
||||||
@ -60,8 +63,10 @@ export const DownloadCertificateButton = ({
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'Sorry, we were unable to download the certificate. Please try again later.',
|
description: _(
|
||||||
|
msg`Sorry, we were unable to download the certificate. Please try again later.`,
|
||||||
|
),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -76,7 +81,7 @@ export const DownloadCertificateButton = ({
|
|||||||
onClick={() => void onDownloadCertificatesClick()}
|
onClick={() => void onDownloadCertificatesClick()}
|
||||||
>
|
>
|
||||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||||
Download Certificate
|
<Trans>Download Certificate</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { DocumentLogsPageView } from './document-logs-page-view';
|
import { DocumentLogsPageView } from './document-logs-page-view';
|
||||||
|
|
||||||
export type DocumentsLogsPageProps = {
|
export type DocumentsLogsPageProps = {
|
||||||
@ -7,5 +9,7 @@ export type DocumentsLogsPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
|
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return <DocumentLogsPageView params={params} />;
|
return <DocumentLogsPageView params={params} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { DocumentPageView } from './document-page-view';
|
import { DocumentPageView } from './document-page-view';
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
@ -7,5 +9,7 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentPage({ params }: DocumentPageProps) {
|
export default function DocumentPage({ params }: DocumentPageProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return <DocumentPageView params={params} />;
|
return <DocumentPageView params={params} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
export default function DocumentSentPage() {
|
export default function DocumentSentPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
<Trans>Loading Document...</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@ -62,6 +64,7 @@ export const ResendDocumentActionItem = ({
|
|||||||
}: ResendDocumentActionItemProps) => {
|
}: ResendDocumentActionItemProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const isOwner = document.userId === session?.user?.id;
|
const isOwner = document.userId === session?.user?.id;
|
||||||
@ -91,16 +94,16 @@ export const ResendDocumentActionItem = ({
|
|||||||
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
|
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document re-sent',
|
title: _(msg`Document re-sent`),
|
||||||
description: 'Your document has been re-sent successfully.',
|
description: _(msg`Your document has been re-sent successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'This document could not be re-sent at this time. Please try again.',
|
description: _(msg`This document could not be re-sent at this time. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -112,14 +115,16 @@ export const ResendDocumentActionItem = ({
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
||||||
<History className="mr-2 h-4 w-4" />
|
<History className="mr-2 h-4 w-4" />
|
||||||
Resend
|
<Trans>Resend</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-sm" hideClose>
|
<DialogContent className="sm:max-w-sm" hideClose>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle asChild>
|
<DialogTitle asChild>
|
||||||
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
<h1 className="text-center text-xl">
|
||||||
|
<Trans>Who do you want to remind?</Trans>
|
||||||
|
</h1>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -178,12 +183,12 @@ export const ResendDocumentActionItem = ({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
|
||||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||||
Send reminder
|
<Trans>Send reminder</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -27,6 +29,7 @@ export type DataTableActionButtonProps = {
|
|||||||
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
|
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
@ -69,8 +72,8 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
|||||||
await downloadPDF({ documentData, fileName: row.title });
|
await downloadPDF({ documentData, fileName: row.title });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'An error occurred while downloading your document.',
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -96,7 +99,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
|||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Edit
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
@ -108,19 +111,19 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
|||||||
.with(RecipientRole.SIGNER, () => (
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<>
|
<>
|
||||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Sign
|
<Trans>Sign</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.APPROVER, () => (
|
.with(RecipientRole.APPROVER, () => (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Approve
|
<Trans>Approve</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.otherwise(() => (
|
||||||
<>
|
<>
|
||||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
View
|
<Trans>View</Trans>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</Link>
|
</Link>
|
||||||
@ -129,13 +132,13 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
|||||||
.with({ isPending: true, isSigned: true }, () => (
|
.with({ isPending: true, isSigned: true }, () => (
|
||||||
<Button className="w-32" disabled={true}>
|
<Button className="w-32" disabled={true}>
|
||||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
View
|
<Trans>View</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-32" onClick={onDownloadClick}>
|
<Button className="w-32" onClick={onDownloadClick}>
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
Download
|
<Trans>Download</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.otherwise(() => <div></div>);
|
.otherwise(() => <div></div>);
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Copy,
|
Copy,
|
||||||
@ -52,6 +54,7 @@ export type DataTableActionDropdownProps = {
|
|||||||
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
@ -98,8 +101,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
await downloadPDF({ documentData, fileName: row.title });
|
await downloadPDF({ documentData, fileName: row.title });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'An error occurred while downloading your document.',
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -114,7 +117,9 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Action</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
|
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
|
||||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||||
@ -122,21 +127,21 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
{recipient?.role === RecipientRole.VIEWER && (
|
{recipient?.role === RecipientRole.VIEWER && (
|
||||||
<>
|
<>
|
||||||
<EyeIcon className="mr-2 h-4 w-4" />
|
<EyeIcon className="mr-2 h-4 w-4" />
|
||||||
View
|
<Trans>View</Trans>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipient?.role === RecipientRole.SIGNER && (
|
{recipient?.role === RecipientRole.SIGNER && (
|
||||||
<>
|
<>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Sign
|
<Trans>Sign</Trans>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipient?.role === RecipientRole.APPROVER && (
|
{recipient?.role === RecipientRole.APPROVER && (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
Approve
|
<Trans>Approve</Trans>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
@ -146,25 +151,25 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Download
|
<Trans>Download</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Duplicate
|
<Trans>Duplicate</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{/* We don't want to allow teams moving documents across at the moment. */}
|
{/* We don't want to allow teams moving documents across at the moment. */}
|
||||||
{!team && (
|
{!team && (
|
||||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||||
<MoveRight className="mr-2 h-4 w-4" />
|
<MoveRight className="mr-2 h-4 w-4" />
|
||||||
Move to Team
|
<Trans>Move to Team</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -179,10 +184,12 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
disabled={Boolean(!canManageDocument && team?.teamEmail)}
|
disabled={Boolean(!canManageDocument && team?.teamEmail)}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{canManageDocument ? 'Delete' : 'Hide'}
|
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Share</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
||||||
|
|
||||||
@ -193,7 +200,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
||||||
Share Signing Card
|
<Trans>Share Signing Card</Trans>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -12,6 +15,8 @@ type DataTableSenderFilterProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
|
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -49,11 +54,13 @@ export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) =>
|
|||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
emptySelectionPlaceholder={
|
emptySelectionPlaceholder={
|
||||||
<p className="text-muted-foreground font-normal">
|
<p className="text-muted-foreground font-normal">
|
||||||
|
<Trans>
|
||||||
<span className="text-muted-foreground/70">Sender:</span> All
|
<span className="text-muted-foreground/70">Sender:</span> All
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
enableClearAllButton={true}
|
enableClearAllButton={true}
|
||||||
inputPlaceholder="Search"
|
inputPlaceholder={msg`Search`}
|
||||||
loading={!isMounted || isInitialLoading}
|
loading={!isMounted || isInitialLoading}
|
||||||
options={comboBoxOptions}
|
options={comboBoxOptions}
|
||||||
selectedValues={senderIds}
|
selectedValues={senderIds}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTransition } from 'react';
|
import { useMemo, useTransition } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
@ -10,6 +12,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
|||||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
|
||||||
@ -39,10 +42,62 @@ export const DocumentsDataTable = ({
|
|||||||
team,
|
team,
|
||||||
}: DocumentsDataTableProps) => {
|
}: DocumentsDataTableProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: _(msg`Created`),
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<LocaleDate
|
||||||
|
date={row.original.createdAt}
|
||||||
|
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Title`),
|
||||||
|
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sender',
|
||||||
|
header: _(msg`Sender`),
|
||||||
|
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Recipient`),
|
||||||
|
accessorKey: 'recipient',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={row.original.Recipient}
|
||||||
|
documentStatus={row.original.status}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Status`),
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||||
|
size: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Actions`),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<DataTableActionButton team={team} row={row.original} />
|
||||||
|
<DataTableActionDropdown team={team} row={row.original} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, [team]);
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
updateSearchParams({
|
updateSearchParams({
|
||||||
@ -59,54 +114,7 @@ export const DocumentsDataTable = ({
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={columns}
|
||||||
{
|
|
||||||
header: 'Created',
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<LocaleDate
|
|
||||||
date={row.original.createdAt}
|
|
||||||
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Title',
|
|
||||||
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sender',
|
|
||||||
header: 'Sender',
|
|
||||||
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Recipient',
|
|
||||||
accessorKey: 'recipient',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<StackAvatarsWithTooltip
|
|
||||||
recipients={row.original.Recipient}
|
|
||||||
documentStatus={row.original.status}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Status',
|
|
||||||
accessorKey: 'status',
|
|
||||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
|
||||||
size: 140,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Actions',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
(!row.original.deletedAt ||
|
|
||||||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<DataTableActionButton team={team} row={row.original} />
|
|
||||||
<DataTableActionDropdown team={team} row={row.original} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={results.data}
|
data={results.data}
|
||||||
perPage={results.perPage}
|
perPage={results.perPage}
|
||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
@ -43,6 +45,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { refreshLimits } = useLimits();
|
const { refreshLimits } = useLimits();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||||
@ -53,8 +56,8 @@ export const DeleteDocumentDialog = ({
|
|||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document deleted',
|
title: _(msg`Document deleted`),
|
||||||
description: `"${documentTitle}" has been successfully deleted`,
|
description: _(msg`"${documentTitle}" has been successfully deleted`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -74,8 +77,8 @@ export const DeleteDocumentDialog = ({
|
|||||||
await deleteDocument({ id, teamId });
|
await deleteDocument({ id, teamId });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'This document could not be deleted at this time. Please try again.',
|
description: _(msg`This document could not be deleted at this time. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -91,11 +94,20 @@ export const DeleteDocumentDialog = ({
|
|||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Are you sure?</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
|
{canManageDocument ? (
|
||||||
<strong>"{documentTitle}"</strong>
|
<Trans>
|
||||||
|
You are about to delete <strong>"{documentTitle}"</strong>
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
You are about to hide <strong>"{documentTitle}"</strong>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -104,33 +116,53 @@ export const DeleteDocumentDialog = ({
|
|||||||
{match(status)
|
{match(status)
|
||||||
.with(DocumentStatus.DRAFT, () => (
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||||
this document will be permanently deleted.
|
this document will be permanently deleted.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with(DocumentStatus.PENDING, () => (
|
.with(DocumentStatus.PENDING, () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
|
<Trans>
|
||||||
Please note that this action is <strong>irreversible</strong>.
|
Please note that this action is <strong>irreversible</strong>.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-1">Once confirmed, the following will occur:</p>
|
<p className="mt-1">
|
||||||
|
<Trans>Once confirmed, the following will occur:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<ul className="mt-0.5 list-inside list-disc">
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
<li>Document will be permanently deleted</li>
|
<li>
|
||||||
<li>Document signing process will be cancelled</li>
|
<Trans>Document will be permanently deleted</Trans>
|
||||||
<li>All inserted signatures will be voided</li>
|
</li>
|
||||||
<li>All recipients will be notified</li>
|
<li>
|
||||||
|
<Trans>Document signing process will be cancelled</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>All inserted signatures will be voided</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>All recipients will be notified</Trans>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>By deleting this document, the following will occur:</p>
|
<p>
|
||||||
|
<Trans>By deleting this document, the following will occur:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<ul className="mt-0.5 list-inside list-disc">
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
<li>The document will be hidden from your account</li>
|
<li>
|
||||||
<li>Recipients will still retain their copy of the document</li>
|
<Trans>The document will be hidden from your account</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Recipients will still retain their copy of the document</Trans>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
@ -139,7 +171,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
) : (
|
) : (
|
||||||
<Alert variant="warning" className="-mt-1">
|
<Alert variant="warning" className="-mt-1">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Please contact support if you would like to revert this action.
|
<Trans>Please contact support if you would like to revert this action.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@ -149,13 +181,13 @@ export const DeleteDocumentDialog = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
placeholder="Type 'delete' to confirm"
|
placeholder={_(msg`Type 'delete' to confirm`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -165,7 +197,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
disabled={!isDeleteEnabled && canManageDocument}
|
disabled={!isDeleteEnabled && canManageDocument}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
{canManageDocument ? 'Delete' : 'Hide'}
|
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
@ -104,7 +106,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h1 className="text-4xl font-semibold">Documents</h1>
|
<h1 className="text-4xl font-semibold">
|
||||||
|
<Trans>Documents</Trans>
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
import type { Team } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
@ -28,7 +31,9 @@ export const DuplicateDocumentDialog = ({
|
|||||||
team,
|
team,
|
||||||
}: DuplicateDocumentDialogProps) => {
|
}: DuplicateDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||||
id,
|
id,
|
||||||
@ -50,8 +55,8 @@ export const DuplicateDocumentDialog = ({
|
|||||||
router.push(`${documentsPath}/${newId}/edit`);
|
router.push(`${documentsPath}/${newId}/edit`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document Duplicated',
|
title: _(msg`Document Duplicated`),
|
||||||
description: 'Your document has been successfully duplicated.',
|
description: _(msg`Your document has been successfully duplicated.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -64,8 +69,8 @@ export const DuplicateDocumentDialog = ({
|
|||||||
await duplicateDocument({ id, teamId: team?.id });
|
await duplicateDocument({ id, teamId: team?.id });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'This document could not be duplicated at this time. Please try again.',
|
description: _(msg`This document could not be duplicated at this time. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -76,12 +81,14 @@ export const DuplicateDocumentDialog = ({
|
|||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Duplicate</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Duplicate</Trans>
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!documentData || isLoading ? (
|
{!documentData || isLoading ? (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
<Trans>Loading Document...</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -98,7 +105,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -108,7 +115,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
onClick={onDuplicate}
|
onClick={onDuplicate}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Duplicate
|
<Trans>Duplicate</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Bird, CheckCircle2 } from 'lucide-react';
|
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -6,33 +8,31 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
|
|||||||
export type EmptyDocumentProps = { status: ExtendedDocumentStatus };
|
export type EmptyDocumentProps = { status: ExtendedDocumentStatus };
|
||||||
|
|
||||||
export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
} = match(status)
|
} = match(status)
|
||||||
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||||
title: 'Nothing to do',
|
title: msg`Nothing to do`,
|
||||||
message:
|
message: msg`There are no completed documents yet. Documents that you have created or received will appear here once completed.`,
|
||||||
'There are no completed documents yet. Documents that you have created or received will appear here once completed.',
|
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
}))
|
}))
|
||||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||||
title: 'No active drafts',
|
title: msg`No active drafts`,
|
||||||
message:
|
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
|
||||||
'There are no active drafts at the current moment. You can upload a document to start drafting.',
|
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
}))
|
}))
|
||||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||||
title: "We're all empty",
|
title: msg`We're all empty`,
|
||||||
message:
|
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
|
||||||
'You have not yet created or received any documents. To create a document please upload one.',
|
|
||||||
icon: Bird,
|
icon: Bird,
|
||||||
}))
|
}))
|
||||||
.otherwise(() => ({
|
.otherwise(() => ({
|
||||||
title: 'Nothing to do',
|
title: msg`Nothing to do`,
|
||||||
message:
|
message: msg`All documents have been processed. Any new documents that are sent or received will show here.`,
|
||||||
'All documents have been processed. Any new documents that are sent or received will show here.',
|
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -44,9 +44,9 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
|||||||
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold">{title}</h3>
|
<h3 className="text-lg font-semibold">{_(title)}</h3>
|
||||||
|
|
||||||
<p className="mt-2 max-w-[60ch]">{message}</p>
|
<p className="mt-2 max-w-[60ch]">{_(message)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
@ -30,25 +33,29 @@ type MoveDocumentDialogProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
|
export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||||
|
|
||||||
const { mutateAsync: moveDocument, isLoading } = trpc.document.moveDocumentToTeam.useMutation({
|
const { mutateAsync: moveDocument, isLoading } = trpc.document.moveDocumentToTeam.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
toast({
|
toast({
|
||||||
title: 'Document moved',
|
title: _(msg`Document moved`),
|
||||||
description: 'The document has been successfully moved to the selected team.',
|
description: _(msg`The document has been successfully moved to the selected team.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: error.message || 'An error occurred while moving the document.',
|
description: error.message || _(msg`An error occurred while moving the document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -56,7 +63,10 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onMove = async () => {
|
const onMove = async () => {
|
||||||
if (!selectedTeamId) return;
|
if (!selectedTeamId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await moveDocument({ documentId, teamId: selectedTeamId });
|
await moveDocument({ documentId, teamId: selectedTeamId });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -64,20 +74,22 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Move Document to Team</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Move Document to Team</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Select a team to move this document to. This action cannot be undone.
|
<Trans>Select a team to move this document to. This action cannot be undone.</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a team" />
|
<SelectValue placeholder={_(msg`Select a team`)} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{isLoadingTeams ? (
|
{isLoadingTeams ? (
|
||||||
<SelectItem value="loading" disabled>
|
<SelectItem value="loading" disabled>
|
||||||
Loading teams...
|
<Trans>Loading teams...</Trans>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
teams?.map((team) => (
|
teams?.map((team) => (
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
import type { DocumentsPageViewProps } from './documents-page-view';
|
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||||
@ -15,7 +16,10 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UpcomingProfileClaimTeaser user={user} />
|
<UpcomingProfileClaimTeaser user={user} />
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@ -12,6 +15,7 @@ export type UpcomingProfileClaimTeaserProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserProps) => {
|
export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -21,14 +25,17 @@ export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserP
|
|||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
if (!open && !claimed) {
|
if (!open && !claimed) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Claim your profile later',
|
title: _(msg`Claim your profile later`),
|
||||||
description: 'You can claim your profile later on by going to your profile settings!',
|
description: _(
|
||||||
|
msg`You can claim your profile later on by going to your profile settings!`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpen(open);
|
setOpen(open);
|
||||||
localStorage.setItem('app.hasShownProfileClaimDialog', 'true');
|
localStorage.setItem('app.hasShownProfileClaimDialog', 'true');
|
||||||
},
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[claimed, toast],
|
[claimed, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
@ -34,6 +36,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { quota, remaining, refreshLimits } = useLimits();
|
const { quota, remaining, refreshLimits } = useLimits();
|
||||||
@ -45,13 +48,14 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
const disabledMessage = useMemo(() => {
|
const disabledMessage = useMemo(() => {
|
||||||
if (remaining.documents === 0) {
|
if (remaining.documents === 0) {
|
||||||
return team
|
return team
|
||||||
? 'Document upload disabled due to unpaid invoices'
|
? msg`Document upload disabled due to unpaid invoices`
|
||||||
: 'You have reached your document limit.';
|
: msg`You have reached your document limit.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session?.user.emailVerified) {
|
if (!session?.user.emailVerified) {
|
||||||
return 'Verify your email to upload documents.';
|
return msg`Verify your email to upload documents.`;
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [remaining.documents, session?.user.emailVerified, team]);
|
}, [remaining.documents, session?.user.emailVerified, team]);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
@ -74,8 +78,8 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document uploaded',
|
title: _(msg`Document uploaded`),
|
||||||
description: 'Your document has been uploaded successfully.',
|
description: _(msg`Your document has been uploaded successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -93,20 +97,20 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
if (error.code === 'INVALID_DOCUMENT_FILE') {
|
if (error.code === 'INVALID_DOCUMENT_FILE') {
|
||||||
toast({
|
toast({
|
||||||
title: 'Invalid file',
|
title: _(msg`Invalid file`),
|
||||||
description: 'You cannot upload encrypted PDFs',
|
description: _(msg`You cannot upload encrypted PDFs`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else if (err instanceof TRPCClientError) {
|
} else if (err instanceof TRPCClientError) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: err.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while uploading your document.',
|
description: _(msg`An error occurred while uploading your document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -117,8 +121,8 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
const onFileDropRejected = () => {
|
const onFileDropRejected = () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Your document failed to upload.',
|
title: _(msg`Your document failed to upload.`),
|
||||||
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@ -139,7 +143,9 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
remaining.documents > 0 &&
|
remaining.documents > 0 &&
|
||||||
Number.isFinite(remaining.documents) && (
|
Number.isFinite(remaining.documents) && (
|
||||||
<p className="text-muted-foreground/60 text-xs">
|
<p className="text-muted-foreground/60 text-xs">
|
||||||
|
<Trans>
|
||||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
|
|||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
|
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||||
@ -22,6 +23,8 @@ export type AuthenticatedDashboardLayoutProps = {
|
|||||||
export default async function AuthenticatedDashboardLayout({
|
export default async function AuthenticatedDashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: AuthenticatedDashboardLayoutProps) {
|
}: AuthenticatedDashboardLayoutProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const session = await getServerSession(NEXT_AUTH_OPTIONS);
|
const session = await getServerSession(NEXT_AUTH_OPTIONS);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
@ -21,11 +24,11 @@ const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
|
|||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
||||||
|
|
||||||
const FRIENDLY_INTERVALS: Record<Interval, string> = {
|
const FRIENDLY_INTERVALS: Record<Interval, MessageDescriptor> = {
|
||||||
day: 'Daily',
|
day: msg`Daily`,
|
||||||
week: 'Weekly',
|
week: msg`Weekly`,
|
||||||
month: 'Monthly',
|
month: msg`Monthly`,
|
||||||
year: 'Yearly',
|
year: msg`Yearly`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MotionCard = motion(Card);
|
const MotionCard = motion(Card);
|
||||||
@ -35,6 +38,7 @@ export type BillingPlansProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
@ -55,8 +59,8 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
|||||||
window.open(url);
|
window.open(url);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'An error occurred while trying to create a checkout session.',
|
description: _(msg`An error occurred while trying to create a checkout session.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@ -72,7 +76,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
|||||||
(interval) =>
|
(interval) =>
|
||||||
prices[interval].length > 0 && (
|
prices[interval].length > 0 && (
|
||||||
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
||||||
{FRIENDLY_INTERVALS[interval]}
|
{_(FRIENDLY_INTERVALS[interval])}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@ -121,7 +125,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
|||||||
loading={isFetchingCheckoutSession}
|
loading={isFetchingCheckoutSession}
|
||||||
onClick={() => void onSubscribeClick(price.id)}
|
onClick={() => void onSubscribeClick(price.id)}
|
||||||
>
|
>
|
||||||
Subscribe
|
<Trans>Subscribe</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</MotionCard>
|
</MotionCard>
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@ -12,6 +15,7 @@ export type BillingPortalButtonProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||||
@ -32,16 +36,18 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
|
|||||||
|
|
||||||
window.open(sessionUrl, '_blank');
|
window.open(sessionUrl, '_blank');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let description =
|
let description = _(
|
||||||
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.';
|
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
||||||
|
);
|
||||||
|
|
||||||
if (e.message === 'CUSTOMER_NOT_FOUND') {
|
if (e.message === 'CUSTOMER_NOT_FOUND') {
|
||||||
description =
|
description = _(
|
||||||
'You do not currently have a customer record, this should not happen. Please contact support for assistance.';
|
msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description,
|
description,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
@ -57,7 +63,7 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
|
|||||||
onClick={async () => handleFetchPortalUrl()}
|
onClick={async () => handleFetchPortalUrl()}
|
||||||
loading={isFetchingPortalUrl}
|
loading={isFetchingPortalUrl}
|
||||||
>
|
>
|
||||||
Manage Subscription
|
<Trans>Manage Subscription</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
|
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
|
||||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
@ -24,6 +26,8 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
let { user } = await getRequiredServerComponentSession();
|
let { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
||||||
@ -66,15 +70,20 @@ export default async function BillingSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold">Billing</h3>
|
<h3 className="text-2xl font-semibold">
|
||||||
|
<Trans>Billing</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 text-sm">
|
<div className="text-muted-foreground mt-2 text-sm">
|
||||||
{isMissingOrInactiveOrFreePlan && (
|
{isMissingOrInactiveOrFreePlan && (
|
||||||
<p>
|
<p>
|
||||||
|
<Trans>
|
||||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Todo: Translation */}
|
||||||
{!isMissingOrInactiveOrFreePlan &&
|
{!isMissingOrInactiveOrFreePlan &&
|
||||||
match(subscription.status)
|
match(subscription.status)
|
||||||
.with('ACTIVE', () => (
|
.with('ACTIVE', () => (
|
||||||
@ -108,7 +117,11 @@ export default async function BillingSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
))
|
))
|
||||||
.with('PAST_DUE', () => (
|
.with('PAST_DUE', () => (
|
||||||
<p>Your current plan is past due. Please update your payment information.</p>
|
<p>
|
||||||
|
<Trans>
|
||||||
|
Your current plan is past due. Please update your payment information.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
))
|
))
|
||||||
.otherwise(() => null)}
|
.otherwise(() => null)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { DesktopNav } from '~/components/(dashboard)/settings/layout/desktop-nav';
|
import { DesktopNav } from '~/components/(dashboard)/settings/layout/desktop-nav';
|
||||||
import { MobileNav } from '~/components/(dashboard)/settings/layout/mobile-nav';
|
import { MobileNav } from '~/components/(dashboard)/settings/layout/mobile-nav';
|
||||||
|
|
||||||
@ -8,9 +12,13 @@ export type DashboardSettingsLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
|
export default function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<h1 className="text-4xl font-semibold">Settings</h1>
|
<h1 className="text-4xl font-semibold">
|
||||||
|
<Trans>Settings</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||||
<DesktopNav className="hidden md:col-span-3 md:flex" />
|
<DesktopNav className="hidden md:col-span-3 md:flex" />
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
@ -28,6 +30,7 @@ export type DeleteAccountDialogProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
|
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
||||||
@ -42,8 +45,8 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
await deleteAccount();
|
await deleteAccount();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Account deleted',
|
title: _(msg`Account deleted`),
|
||||||
description: 'Your account has been deleted successfully.',
|
description: _(msg`Your account has been deleted successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -51,17 +54,19 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
toast({
|
toast({
|
||||||
title: 'An error occurred',
|
title: _(msg`An error occurred`),
|
||||||
description: err.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: _(msg`An unknown error occurred`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description:
|
description:
|
||||||
err.message ??
|
err.message ??
|
||||||
'We encountered an unknown error while attempting to delete your account. Please try again later.',
|
_(
|
||||||
|
msg`We encountered an unknown error while attempting to delete your account. Please try again later.`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -74,50 +79,63 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<AlertTitle>Delete Account</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Delete Account</Trans>
|
||||||
|
</AlertTitle>
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
Delete your account and all its contents, including completed documents. This action is
|
<Trans>
|
||||||
irreversible and will cancel your subscription, so proceed with caution.
|
Delete your account and all its contents, including completed documents. This action
|
||||||
|
is irreversible and will cancel your subscription, so proceed with caution.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Dialog onOpenChange={() => setEnteredEmail('')}>
|
<Dialog onOpenChange={() => setEnteredEmail('')}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">Delete Account</Button>
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete Account</Trans>
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="space-y-4">
|
||||||
<DialogTitle>Delete Account</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Delete Account</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription className="selection:bg-red-100">
|
<AlertDescription className="selection:bg-red-100">
|
||||||
This action is not reversible. Please be certain.
|
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{hasTwoFactorAuthentication && (
|
{hasTwoFactorAuthentication && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription className="selection:bg-red-100">
|
<AlertDescription className="selection:bg-red-100">
|
||||||
Disable Two Factor Authentication before deleting your account.
|
<Trans>Disable Two Factor Authentication before deleting your account.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Documenso will delete <span className="font-semibold">all of your documents</span>
|
<Trans>
|
||||||
, along with all of your completed documents, signatures, and all other resources
|
Documenso will delete{' '}
|
||||||
belonging to your Account.
|
<span className="font-semibold">all of your documents</span>, along with all of
|
||||||
|
your completed documents, signatures, and all other resources belonging to your
|
||||||
|
Account.
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{!hasTwoFactorAuthentication && (
|
{!hasTwoFactorAuthentication && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Label>
|
<Label>
|
||||||
|
<Trans>
|
||||||
Please type{' '}
|
Please type{' '}
|
||||||
<span className="text-muted-foreground font-semibold">{user.email}</span> to
|
<span className="text-muted-foreground font-semibold">{user.email}</span> to
|
||||||
confirm.
|
confirm.
|
||||||
|
</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@ -136,7 +154,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={hasTwoFactorAuthentication || enteredEmail !== user.email}
|
disabled={hasTwoFactorAuthentication || enteredEmail !== user.email}
|
||||||
>
|
>
|
||||||
{isDeletingAccount ? 'Deleting account...' : 'Confirm Deletion'}
|
{isDeletingAccount ? _(msg`Deleting account...`) : _(msg`Confirm Deletion`)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
@ -13,11 +17,17 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function ProfileSettingsPage() {
|
export default async function ProfileSettingsPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
<SettingsHeader
|
||||||
|
title={_(msg`Profile`)}
|
||||||
|
subtitle={_(msg`Here you can edit your personal details.`)}
|
||||||
|
/>
|
||||||
|
|
||||||
<AvatarImageForm className="mb-8 max-w-xl" user={user} />
|
<AvatarImageForm className="mb-8 max-w-xl" user={user} />
|
||||||
<ProfileForm className="mb-8 max-w-xl" user={user} />
|
<ProfileForm className="mb-8 max-w-xl" user={user} />
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
|
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
|
||||||
|
|
||||||
import { PublicProfilePageView } from './public-profile-page-view';
|
import { PublicProfilePageView } from './public-profile-page-view';
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const { profile } = await getUserPublicProfile({
|
const { profile } = await getUserPublicProfile({
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||||
import type {
|
import type {
|
||||||
Team,
|
Team,
|
||||||
@ -36,22 +39,21 @@ type DirectTemplate = FindTemplateRow & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const userProfileText = {
|
const userProfileText = {
|
||||||
settingsTitle: 'Public Profile',
|
settingsTitle: msg`Public Profile`,
|
||||||
settingsSubtitle: 'You can choose to enable or disable your profile for public view.',
|
settingsSubtitle: msg`You can choose to enable or disable your profile for public view.`,
|
||||||
templatesTitle: 'My templates',
|
templatesTitle: msg`My templates`,
|
||||||
templatesSubtitle:
|
templatesSubtitle: msg`Show templates in your public profile for your audience to sign and get started quickly`,
|
||||||
'Show templates in your public profile for your audience to sign and get started quickly',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const teamProfileText = {
|
const teamProfileText = {
|
||||||
settingsTitle: 'Team Public Profile',
|
settingsTitle: msg`Team Public Profile`,
|
||||||
settingsSubtitle: 'You can choose to enable or disable your team profile for public view.',
|
settingsSubtitle: msg`You can choose to enable or disable your team profile for public view.`,
|
||||||
templatesTitle: 'Team templates',
|
templatesTitle: msg`Team templates`,
|
||||||
templatesSubtitle:
|
templatesSubtitle: msg`Show templates in your team public profile for your audience to sign and get started quickly`,
|
||||||
'Show templates in your team public profile for your audience to sign and get started quickly',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => {
|
export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
|
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
|
||||||
@ -104,7 +106,7 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
|
|
||||||
if (isVisible && !user.url) {
|
if (isVisible && !user.url) {
|
||||||
toast({
|
toast({
|
||||||
title: 'You must set a profile URL before enabling your public profile.',
|
title: _(msg`You must set a profile URL before enabling your public profile.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -119,8 +121,8 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'We were unable to set your public profile to public. Please try again.',
|
description: _(msg`We were unable to set your public profile to public. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -134,7 +136,10 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<SettingsHeader title={profileText.settingsTitle} subtitle={profileText.settingsSubtitle}>
|
<SettingsHeader
|
||||||
|
title={_(profileText.settingsTitle)}
|
||||||
|
subtitle={_(profileText.settingsSubtitle)}
|
||||||
|
>
|
||||||
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
|
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
@ -146,13 +151,17 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>Hide</span>
|
<span>
|
||||||
|
<Trans>Hide</Trans>
|
||||||
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
checked={isPublicProfileVisible}
|
checked={isPublicProfileVisible}
|
||||||
onCheckedChange={togglePublicProfileVisibility}
|
onCheckedChange={togglePublicProfileVisibility}
|
||||||
/>
|
/>
|
||||||
<span>Show</span>
|
<span>
|
||||||
|
<Trans>Show</Trans>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
@ -160,18 +169,26 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
{isPublicProfileVisible ? (
|
{isPublicProfileVisible ? (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
|
<Trans>
|
||||||
Profile is currently <strong>visible</strong>.
|
Profile is currently <strong>visible</strong>.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>Toggle the switch to hide your profile from the public.</p>
|
<p>
|
||||||
|
<Trans>Toggle the switch to hide your profile from the public.</Trans>
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
|
<Trans>
|
||||||
Profile is currently <strong>hidden</strong>.
|
Profile is currently <strong>hidden</strong>.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>Toggle the switch to show your profile to the public.</p>
|
<p>
|
||||||
|
<Trans>Toggle the switch to show your profile to the public.</Trans>
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@ -187,14 +204,18 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={profileText.templatesTitle}
|
title={_(profileText.templatesTitle)}
|
||||||
subtitle={profileText.templatesSubtitle}
|
subtitle={_(profileText.templatesSubtitle)}
|
||||||
hideDivider={true}
|
hideDivider={true}
|
||||||
className="mt-8 [&>*>h3]:text-base"
|
className="mt-8 [&>*>h3]:text-base"
|
||||||
>
|
>
|
||||||
<ManagePublicTemplateDialog
|
<ManagePublicTemplateDialog
|
||||||
directTemplates={enabledPrivateDirectTemplates}
|
directTemplates={enabledPrivateDirectTemplates}
|
||||||
trigger={<Button variant="outline">Link template</Button>}
|
trigger={
|
||||||
|
<Button variant="outline">
|
||||||
|
<Trans>Link template</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
|
||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
@ -30,6 +32,7 @@ type DirectTemplate = FindTemplateRow & {
|
|||||||
export const PublicTemplatesDataTable = () => {
|
export const PublicTemplatesDataTable = () => {
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [, copy] = useCopyToClipboard();
|
const [, copy] = useCopyToClipboard();
|
||||||
@ -71,8 +74,8 @@ export const PublicTemplatesDataTable = () => {
|
|||||||
const onCopyClick = async (token: string) =>
|
const onCopyClick = async (token: string) =>
|
||||||
copy(formatDirectTemplatePath(token)).then(() => {
|
copy(formatDirectTemplatePath(token)).then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Copied to clipboard',
|
title: _(msg`Copied to clipboard`),
|
||||||
description: 'The direct link has been copied to your clipboard',
|
description: _(msg`The direct link has been copied to your clipboard`),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -105,26 +108,26 @@ export const PublicTemplatesDataTable = () => {
|
|||||||
|
|
||||||
{isLoadingError && (
|
{isLoadingError && (
|
||||||
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
||||||
Unable to load your public profile templates at this time
|
<Trans>Unable to load your public profile templates at this time</Trans>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void refetch();
|
void refetch();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Click here to retry
|
<Trans>Click here to retry</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isInitialLoading && (
|
{!isInitialLoading && (
|
||||||
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
||||||
No public profile templates found
|
<Trans>No public profile templates found</Trans>
|
||||||
<ManagePublicTemplateDialog
|
<ManagePublicTemplateDialog
|
||||||
directTemplates={privateDirectTemplates}
|
directTemplates={privateDirectTemplates}
|
||||||
trigger={
|
trigger={
|
||||||
<button className="hover:text-muted-foreground/80 mt-1 text-xs">
|
<button className="hover:text-muted-foreground/80 mt-1 text-xs">
|
||||||
Click here to get started
|
<Trans>Click here to get started</Trans>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -157,11 +160,13 @@ export const PublicTemplatesDataTable = () => {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="center" side="left">
|
<DropdownMenuContent className="w-52" align="center" side="left">
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Action</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => void onCopyClick(template.directLink.token)}>
|
<DropdownMenuItem onClick={() => void onCopyClick(template.directLink.token)}>
|
||||||
<LinkIcon className="mr-2 h-4 w-4" />
|
<LinkIcon className="mr-2 h-4 w-4" />
|
||||||
Copy sharable link
|
<Trans>Copy sharable link</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@ -173,7 +178,7 @@ export const PublicTemplatesDataTable = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditIcon className="mr-2 h-4 w-4" />
|
<EditIcon className="mr-2 h-4 w-4" />
|
||||||
Update
|
<Trans>Update</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@ -185,7 +190,7 @@ export const PublicTemplatesDataTable = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||||
Remove
|
<Trans>Remove</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
|
||||||
import ActivityPageBackButton from '../../../../../components/(dashboard)/settings/layout/activity-back';
|
import ActivityPageBackButton from '../../../../../components/(dashboard)/settings/layout/activity-back';
|
||||||
@ -10,11 +15,15 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsSecurityActivityPage() {
|
export default function SettingsSecurityActivityPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title="Security activity"
|
title={_(msg`Security activity`)}
|
||||||
subtitle="View all recent security activity related to your account."
|
subtitle={_(msg`View all security activity related to your account.`)}
|
||||||
hideDivider={true}
|
hideDivider={true}
|
||||||
>
|
>
|
||||||
<ActivityPageBackButton />
|
<ActivityPageBackButton />
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
@ -10,6 +14,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
|||||||
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
|
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
|
||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
@ -23,7 +28,7 @@ const dateFormat: DateTimeFormatOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const UserSecurityActivityDataTable = () => {
|
export const UserSecurityActivityDataTable = () => {
|
||||||
const parser = new UAParser();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -59,16 +64,17 @@ export const UserSecurityActivityDataTable = () => {
|
|||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const columns = useMemo(() => {
|
||||||
<DataTable
|
const parser = new UAParser();
|
||||||
columns={[
|
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
header: 'Date',
|
header: _(msg`Date`),
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Device',
|
header: _(msg`Device`),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (!row.original.userAgent) {
|
if (!row.original.userAgent) {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@ -92,7 +98,7 @@ export const UserSecurityActivityDataTable = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Browser',
|
header: _(msg`Browser`),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (!row.original.userAgent) {
|
if (!row.original.userAgent) {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@ -111,11 +117,16 @@ export const UserSecurityActivityDataTable = () => {
|
|||||||
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: _(msg`Action`),
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
|
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
|
||||||
},
|
},
|
||||||
]}
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
data={results.data}
|
data={results.data}
|
||||||
perPage={results.perPage}
|
perPage={results.perPage}
|
||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
@ -17,6 +21,9 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function SecuritySettingsPage() {
|
export default async function SecuritySettingsPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||||
@ -24,8 +31,8 @@ export default async function SecuritySettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title="Security"
|
title={_(msg`Security`)}
|
||||||
subtitle="Here you can manage your password and security settings."
|
subtitle={_(msg`Here you can manage your password and security settings.`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{user.identityProvider === 'DOCUMENSO' && (
|
{user.identityProvider === 'DOCUMENSO' && (
|
||||||
@ -41,13 +48,22 @@ export default async function SecuritySettingsPage() {
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
<AlertTitle>Two factor authentication</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Two factor authentication</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription className="mr-4">
|
<AlertDescription className="mr-4">
|
||||||
Add an authenticator to serve as a secondary authentication method{' '}
|
{user.identityProvider === 'DOCUMENSO' ? (
|
||||||
{user.identityProvider === 'DOCUMENSO'
|
<Trans>
|
||||||
? 'when signing in, or when signing documents.'
|
Add an authenticator to serve as a secondary authentication method when signing in,
|
||||||
: 'for signing documents.'}
|
or when signing documents.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Add an authenticator to serve as a secondary authentication method for signing
|
||||||
|
documents.
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -64,11 +80,15 @@ export default async function SecuritySettingsPage() {
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
<AlertTitle>Recovery codes</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Recovery codes</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription className="mr-4">
|
<AlertDescription className="mr-4">
|
||||||
Two factor authentication recovery codes are used to access your account in the event
|
<Trans>
|
||||||
that you lose access to your authenticator app.
|
Two factor authentication recovery codes are used to access your account in the
|
||||||
|
event that you lose access to your authenticator app.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -82,15 +102,21 @@ export default async function SecuritySettingsPage() {
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
<AlertTitle>Passkeys</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Passkeys</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription className="mr-4">
|
<AlertDescription className="mr-4">
|
||||||
|
<Trans>
|
||||||
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild variant="outline" className="bg-background">
|
<Button asChild variant="outline" className="bg-background">
|
||||||
<Link href="/settings/security/passkeys">Manage passkeys</Link>
|
<Link href="/settings/security/passkeys">
|
||||||
|
<Trans>Manage passkeys</Trans>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@ -100,15 +126,19 @@ export default async function SecuritySettingsPage() {
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div className="mb-4 mr-4 sm:mb-0">
|
<div className="mb-4 mr-4 sm:mb-0">
|
||||||
<AlertTitle>Recent activity</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Recent activity</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
View all recent security activity related to your account.
|
<Trans>View all recent security activity related to your account.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild variant="outline" className="bg-background">
|
<Button asChild variant="outline" className="bg-background">
|
||||||
<Link href="/settings/security/activity">View activity</Link>
|
<Link href="/settings/security/activity">
|
||||||
|
<Trans>View activity</Trans>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
import { KeyRoundIcon } from 'lucide-react';
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
@ -53,6 +55,7 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const form = useForm<TCreatePasskeyFormSchema>({
|
const form = useForm<TCreatePasskeyFormSchema>({
|
||||||
@ -81,7 +84,7 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
description: 'Successfully created passkey',
|
description: _(msg`Successfully created passkey`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -140,17 +143,22 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
|
|||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary" loading={isLoading}>
|
<Button variant="secondary" loading={isLoading}>
|
||||||
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
|
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
|
||||||
Add passkey
|
<Trans>Add passkey</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent position="center">
|
<DialogContent position="center">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add passkey</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Add passkey</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription className="mt-4">
|
||||||
Passkeys allow you to sign in and authenticate using biometrics, password managers, etc.
|
<Trans>
|
||||||
|
Passkeys allow you to sign in and authenticate using biometrics, password managers,
|
||||||
|
etc.
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -165,7 +173,9 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
|
|||||||
name="passkeyName"
|
name="passkeyName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel required>Passkey name</FormLabel>
|
<FormLabel required>
|
||||||
|
<Trans>Passkey name</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" placeholder="eg. Mac" {...field} />
|
<Input className="bg-background" placeholder="eg. Mac" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -176,13 +186,17 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
|
|||||||
|
|
||||||
<Alert variant="neutral">
|
<Alert variant="neutral">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
When you click continue, you will be prompted to add the first available
|
When you click continue, you will be prompted to add the first available
|
||||||
authenticator on your system.
|
authenticator on your system.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|
||||||
<AlertDescription className="mt-2">
|
<AlertDescription className="mt-2">
|
||||||
If you do not want to use the authenticator prompted, you can close it, which will
|
<Trans>
|
||||||
then display the next available authenticator.
|
If you do not want to use the authenticator prompted, you can close it, which
|
||||||
|
will then display the next available authenticator.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
@ -190,30 +204,40 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
|
|||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
{match(formError)
|
{match(formError)
|
||||||
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
|
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
|
||||||
<AlertDescription>This passkey has already been registered.</AlertDescription>
|
<AlertDescription>
|
||||||
|
<Trans>This passkey has already been registered.</Trans>
|
||||||
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with('TOO_MANY_PASSKEYS', () => (
|
.with('TOO_MANY_PASSKEYS', () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You cannot have more than {MAXIMUM_PASSKEYS} passkeys.
|
<Trans>You cannot have more than {MAXIMUM_PASSKEYS} passkeys.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with('InvalidStateError', () => (
|
.with('InvalidStateError', () => (
|
||||||
<>
|
<>
|
||||||
<AlertTitle className="text-sm">
|
<AlertTitle className="text-sm">
|
||||||
|
<Trans>
|
||||||
Passkey creation cancelled due to one of the following reasons:
|
Passkey creation cancelled due to one of the following reasons:
|
||||||
|
</Trans>
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<ul className="mt-1 list-inside list-disc">
|
<ul className="mt-1 list-inside list-disc">
|
||||||
<li>Cancelled by user</li>
|
<li>
|
||||||
<li>Passkey already exists for the provided authenticator</li>
|
<Trans>Cancelled by user</Trans>
|
||||||
<li>Exceeded timeout</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Passkey already exists for the provided authenticator</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Exceeded timeout</Trans>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.otherwise(() => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Something went wrong. Please try again or contact support.
|
<Trans>Something went wrong. Please try again or contact support.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))}
|
))}
|
||||||
</Alert>
|
</Alert>
|
||||||
@ -221,11 +245,11 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
Continue
|
<Trans>Continue</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
@ -13,6 +17,9 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function SettingsManagePasskeysPage() {
|
export default async function SettingsManagePasskeysPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||||
|
|
||||||
if (!isPasskeyEnabled) {
|
if (!isPasskeyEnabled) {
|
||||||
@ -21,7 +28,11 @@ export default async function SettingsManagePasskeysPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title="Passkeys" subtitle="Manage your passkeys." hideDivider={true}>
|
<SettingsHeader
|
||||||
|
title={_(msg`Passkeys`)}
|
||||||
|
subtitle={_(msg`Manage your passkeys.`)}
|
||||||
|
hideDivider={true}
|
||||||
|
>
|
||||||
<CreatePasskeyDialog />
|
<CreatePasskeyDialog />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@ -45,6 +47,7 @@ export const UserPasskeysDataTableActions = ({
|
|||||||
passkeyId,
|
passkeyId,
|
||||||
passkeyName,
|
passkeyName,
|
||||||
}: UserPasskeysDataTableActionsProps) => {
|
}: UserPasskeysDataTableActionsProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
@ -61,15 +64,16 @@ export const UserPasskeysDataTableActions = ({
|
|||||||
trpc.auth.updatePasskey.useMutation({
|
trpc.auth.updatePasskey.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: _(msg`Success`),
|
||||||
description: 'Passkey has been updated',
|
description: _(msg`Passkey has been updated`),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description:
|
description: _(
|
||||||
'We are unable to update this passkey at the moment. Please try again later.',
|
msg`We are unable to update this passkey at the moment. Please try again later.`,
|
||||||
|
),
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@ -80,15 +84,16 @@ export const UserPasskeysDataTableActions = ({
|
|||||||
trpc.auth.deletePasskey.useMutation({
|
trpc.auth.deletePasskey.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: _(msg`Success`),
|
||||||
description: 'Passkey has been removed',
|
description: _(msg`Passkey has been removed`),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description:
|
description: _(
|
||||||
'We are unable to remove this passkey at the moment. Please try again later.',
|
msg`We are unable to remove this passkey at the moment. Please try again later.`,
|
||||||
|
),
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@ -102,15 +107,21 @@ export const UserPasskeysDataTableActions = ({
|
|||||||
onOpenChange={(value) => !isUpdatingPasskey && setIsUpdateDialogOpen(value)}
|
onOpenChange={(value) => !isUpdatingPasskey && setIsUpdateDialogOpen(value)}
|
||||||
>
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
<Button variant="outline">Edit</Button>
|
<Button variant="outline">
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent position="center">
|
<DialogContent position="center">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Update passkey</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Update passkey</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>
|
||||||
You are currently updating the <strong>{passkeyName}</strong> passkey.
|
You are currently updating the <strong>{passkeyName}</strong> passkey.
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -129,7 +140,9 @@ export const UserPasskeysDataTableActions = ({
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
<FormLabel required>Name</FormLabel>
|
<FormLabel required>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -141,12 +154,12 @@ export const UserPasskeysDataTableActions = ({
|
|||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="button" variant="secondary">
|
<Button type="button" variant="secondary">
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
|
||||||
<Button type="submit" loading={isUpdatingPasskey}>
|
<Button type="submit" loading={isUpdatingPasskey}>
|
||||||
Update
|
<Trans>Update</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -160,15 +173,21 @@ export const UserPasskeysDataTableActions = ({
|
|||||||
onOpenChange={(value) => !isDeletingPasskey && setIsDeleteDialogOpen(value)}
|
onOpenChange={(value) => !isDeletingPasskey && setIsDeleteDialogOpen(value)}
|
||||||
>
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
<Button variant="destructive">Delete</Button>
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent position="center">
|
<DialogContent position="center">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete passkey</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Delete passkey</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>
|
||||||
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
|
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -176,7 +195,7 @@ export const UserPasskeysDataTableActions = ({
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="button" variant="secondary">
|
<Button type="button" variant="secondary">
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
|
||||||
@ -189,7 +208,7 @@ export const UserPasskeysDataTableActions = ({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
loading={isDeletingPasskey}
|
loading={isDeletingPasskey}
|
||||||
>
|
>
|
||||||
Delete
|
<Trans>Delete</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
@ -15,6 +20,8 @@ import { TableCell } from '@documenso/ui/primitives/table';
|
|||||||
import { UserPasskeysDataTableActions } from './user-passkeys-data-table-actions';
|
import { UserPasskeysDataTableActions } from './user-passkeys-data-table-actions';
|
||||||
|
|
||||||
export const UserPasskeysDataTable = () => {
|
export const UserPasskeysDataTable = () => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@ -48,26 +55,25 @@ export const UserPasskeysDataTable = () => {
|
|||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const columns = useMemo(() => {
|
||||||
<DataTable
|
return [
|
||||||
columns={[
|
|
||||||
{
|
{
|
||||||
header: 'Name',
|
header: _(msg`Name`),
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Created',
|
header: _(msg`Created`),
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(),
|
cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
header: 'Last used',
|
header: _(msg`Last used`),
|
||||||
accessorKey: 'updatedAt',
|
accessorKey: 'updatedAt',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.lastUsedAt
|
row.original.lastUsedAt
|
||||||
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
|
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
|
||||||
: 'Never',
|
: msg`Never`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@ -79,7 +85,12 @@ export const UserPasskeysDataTable = () => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
data={results.data}
|
data={results.data}
|
||||||
perPage={results.perPage}
|
perPage={results.perPage}
|
||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@ -9,6 +12,7 @@ export type AcceptTeamInvitationButtonProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
|
export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -18,17 +22,17 @@ export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButto
|
|||||||
} = trpc.team.acceptTeamInvitation.useMutation({
|
} = trpc.team.acceptTeamInvitation.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: _(msg`Success`),
|
||||||
description: 'Accepted team invitation',
|
description: _(msg`Accepted team invitation`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(msg`Unable to join this team at this time.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
description: 'Unable to join this team at this time.',
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -39,7 +43,7 @@ export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButto
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={isLoading || isSuccess}
|
disabled={isLoading || isSuccess}
|
||||||
>
|
>
|
||||||
Accept
|
<Trans>Accept</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@ -9,6 +12,7 @@ export type DeclineTeamInvitationButtonProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationButtonProps) => {
|
export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationButtonProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -18,17 +22,17 @@ export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationBut
|
|||||||
} = trpc.team.declineTeamInvitation.useMutation({
|
} = trpc.team.declineTeamInvitation.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: _(msg`Success`),
|
||||||
description: 'Declined team invitation',
|
description: _(msg`Declined team invitation`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(msg`Unable to decline this team invitation at this time.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
description: 'Unable to decline this team invitation at this time.',
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -40,7 +44,7 @@ export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationBut
|
|||||||
disabled={isLoading || isSuccess}
|
disabled={isLoading || isSuccess}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Decline
|
<Trans>Decline</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -13,11 +15,16 @@ import { TeamEmailUsage } from './team-email-usage';
|
|||||||
import { TeamInvitations } from './team-invitations';
|
import { TeamInvitations } from './team-invitations';
|
||||||
|
|
||||||
export default function TeamsSettingsPage() {
|
export default function TeamsSettingsPage() {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
|
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title="Teams" subtitle="Manage all teams you are currently associated with.">
|
<SettingsHeader
|
||||||
|
title={_(msg`Teams`)}
|
||||||
|
subtitle={_(msg`Manage all teams you are currently associated with.`)}
|
||||||
|
>
|
||||||
<CreateTeamDialog />
|
<CreateTeamDialog />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import type { TeamEmail } from '@documenso/prisma/client';
|
import type { TeamEmail } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
@ -24,24 +27,26 @@ export type TeamEmailUsageProps = {
|
|||||||
export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
|
export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
|
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
|
||||||
trpc.team.deleteTeamEmail.useMutation({
|
trpc.team.deleteTeamEmail.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: _(msg`Success`),
|
||||||
description: 'You have successfully revoked access.',
|
description: _(msg`You have successfully revoked access.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to revoke access. Please try again or contact support.`,
|
||||||
|
),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to revoke access. Please try again or contact support.',
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -49,43 +54,59 @@ export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
|
|||||||
return (
|
return (
|
||||||
<Alert variant="neutral" className="flex flex-row items-center justify-between p-6">
|
<Alert variant="neutral" className="flex flex-row items-center justify-between p-6">
|
||||||
<div>
|
<div>
|
||||||
<AlertTitle className="mb-0">Team Email</AlertTitle>
|
<AlertTitle className="mb-0">
|
||||||
|
<Trans>Team Email</Trans>
|
||||||
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
|
<Trans>
|
||||||
Your email is currently being used by team{' '}
|
Your email is currently being used by team{' '}
|
||||||
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
|
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
|
||||||
).
|
).
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-1">They have permission on your behalf to:</p>
|
<p className="mt-1">
|
||||||
|
<Trans>They have permission on your behalf to:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<ul className="mt-0.5 list-inside list-disc">
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
<li>Display your name and email in documents</li>
|
<li>
|
||||||
<li>View all documents sent to your account</li>
|
<Trans>Display your name and email in documents</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>View all documents sent to your account</Trans>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">Revoke access</Button>
|
<Button variant="destructive">
|
||||||
|
<Trans>Revoke access</Trans>
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent position="center">
|
<DialogContent position="center">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Are you sure?</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>
|
||||||
You are about to revoke access for team{' '}
|
You are about to revoke access for team{' '}
|
||||||
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}) to
|
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url})
|
||||||
use your email.
|
to use your email.
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<fieldset disabled={isDeletingTeamEmail}>
|
<fieldset disabled={isDeletingTeamEmail}>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -94,7 +115,7 @@ export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
|
|||||||
loading={isDeletingTeamEmail}
|
loading={isDeletingTeamEmail}
|
||||||
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
|
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
|
||||||
>
|
>
|
||||||
Revoke
|
<Trans>Revoke</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Plural, Trans } from '@lingui/macro';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import { BellIcon } from 'lucide-react';
|
import { BellIcon } from 'lucide-react';
|
||||||
|
|
||||||
@ -33,23 +34,48 @@ export const TeamInvitations = () => {
|
|||||||
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
||||||
|
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
You have <strong>{data.length}</strong> pending team invitation
|
<Plural
|
||||||
{data.length > 1 ? 's' : ''}.
|
value={data.length}
|
||||||
|
one={
|
||||||
|
<span>
|
||||||
|
You have <strong>1</strong> pending team invitation
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
other={
|
||||||
|
<span>
|
||||||
|
You have <strong>#</strong> pending team invitations
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
|
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
|
||||||
View invites
|
<Trans>View invites</Trans>
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent position="center">
|
<DialogContent position="center">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Pending invitations</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Pending invitations</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription className="mt-4">
|
||||||
You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
|
<Plural
|
||||||
|
value={data.length}
|
||||||
|
one={
|
||||||
|
<span>
|
||||||
|
You have <strong>1</strong> pending team invitation
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
other={
|
||||||
|
<span>
|
||||||
|
You have <strong>#</strong> pending team invitations
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -9,15 +11,20 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
|||||||
import { ApiTokenForm } from '~/components/forms/token';
|
import { ApiTokenForm } from '~/components/forms/token';
|
||||||
|
|
||||||
export default async function ApiTokensPage() {
|
export default async function ApiTokensPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const tokens = await getUserTokens({ userId: user.id });
|
const tokens = await getUserTokens({ userId: user.id });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold">API Tokens</h3>
|
<h3 className="text-2xl font-semibold">
|
||||||
|
<Trans>API Tokens</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
<Trans>
|
||||||
On this page, you can create new API tokens and manage the existing ones. <br />
|
On this page, you can create new API tokens and manage the existing ones. <br />
|
||||||
Also see our{' '}
|
Also see our{' '}
|
||||||
<a
|
<a
|
||||||
@ -28,6 +35,7 @@ export default async function ApiTokensPage() {
|
|||||||
Documentation
|
Documentation
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
@ -36,12 +44,14 @@ export default async function ApiTokensPage() {
|
|||||||
|
|
||||||
<hr className="mb-4 mt-8" />
|
<hr className="mb-4 mt-8" />
|
||||||
|
|
||||||
<h4 className="text-xl font-medium">Your existing tokens</h4>
|
<h4 className="text-xl font-medium">
|
||||||
|
<Trans>Your existing tokens</Trans>
|
||||||
|
</h4>
|
||||||
|
|
||||||
{tokens.length === 0 && (
|
{tokens.length === 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
<p className="text-muted-foreground mt-2 text-sm italic">
|
||||||
Your tokens will be shown here once you create them.
|
<Trans>Your tokens will be shown here once you create them.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -55,22 +65,26 @@ export default async function ApiTokensPage() {
|
|||||||
<h5 className="text-base">{token.name}</h5>
|
<h5 className="text-base">{token.name}</h5>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
|
<Trans>Created on</Trans>{' '}
|
||||||
|
<LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
|
||||||
</p>
|
</p>
|
||||||
{token.expires ? (
|
{token.expires ? (
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
|
<Trans>Expires on</Trans>{' '}
|
||||||
|
<LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
Token doesn't have an expiration date
|
<Trans>Token doesn't have an expiration date</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<DeleteTokenDialog token={token}>
|
<DeleteTokenDialog token={token}>
|
||||||
<Button variant="destructive">Delete</Button>
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
</DeleteTokenDialog>
|
</DeleteTokenDialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
@ -38,6 +40,7 @@ export type WebhookPageOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function WebhookPage({ params }: WebhookPageOptions) {
|
export default function WebhookPage({ params }: WebhookPageOptions) {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -68,16 +71,18 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Webhook updated',
|
title: _(msg`Webhook updated`),
|
||||||
description: 'The webhook has been updated successfully.',
|
description: _(msg`The webhook has been updated successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to update webhook',
|
title: _(msg`Failed to update webhook`),
|
||||||
description: 'We encountered an error while updating the webhook. Please try again later.',
|
description: _(
|
||||||
|
msg`We encountered an error while updating the webhook. Please try again later.`,
|
||||||
|
),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -86,8 +91,8 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title="Edit webhook"
|
title={_(msg`Edit webhook`)}
|
||||||
subtitle="On this page, you can edit the webhook and its settings."
|
subtitle={_(msg`On this page, you can edit the webhook and its settings.`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@ -108,13 +113,15 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
name="webhookUrl"
|
name="webhookUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormLabel required>Webhook URL</FormLabel>
|
<FormLabel required>
|
||||||
|
<Trans>Webhook URL</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} />
|
<Input className="bg-background" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The URL for Documenso to send webhook events to.
|
<Trans>The URL for Documenso to send webhook events to.</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -127,7 +134,9 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
name="enabled"
|
name="enabled"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Enabled</FormLabel>
|
<FormLabel>
|
||||||
|
<Trans>Enabled</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@ -150,7 +159,9 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
name="eventTriggers"
|
name="eventTriggers"
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<FormItem className="flex flex-col gap-2">
|
<FormItem className="flex flex-col gap-2">
|
||||||
<FormLabel required>Triggers</FormLabel>
|
<FormLabel required>
|
||||||
|
<Trans>Triggers</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TriggerMultiSelectCombobox
|
<TriggerMultiSelectCombobox
|
||||||
listValues={value}
|
listValues={value}
|
||||||
@ -161,7 +172,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The events that will trigger a webhook to be sent to your URL.
|
<Trans> The events that will trigger a webhook to be sent to your URL.</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -174,14 +185,18 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
name="secret"
|
name="secret"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Secret</FormLabel>
|
<FormLabel>
|
||||||
|
<Trans>Secret</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
A secret that will be sent to your URL so you can verify that the request has
|
A secret that will be sent to your URL so you can verify that the request has
|
||||||
been sent by Documenso.
|
been sent by Documenso.
|
||||||
|
</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -190,7 +205,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
Update webhook
|
<Trans>Update webhook</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
@ -17,13 +19,15 @@ import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/
|
|||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
export default function WebhookPage() {
|
export default function WebhookPage() {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
|
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title="Webhooks"
|
title={_(msg`Webhooks`)}
|
||||||
subtitle="On this page, you can create new Webhooks and manage the existing ones."
|
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
|
||||||
>
|
>
|
||||||
<CreateWebhookDialog />
|
<CreateWebhookDialog />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
@ -38,7 +42,9 @@ export default function WebhookPage() {
|
|||||||
// TODO: Perhaps add some illustrations here to make the page more engaging
|
// TODO: Perhaps add some illustrations here to make the page more engaging
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
<p className="text-muted-foreground mt-2 text-sm italic">
|
||||||
|
<Trans>
|
||||||
You have no webhooks yet. Your webhooks will be shown here once you create them.
|
You have no webhooks yet. Your webhooks will be shown here once you create them.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -66,29 +72,37 @@ export default function WebhookPage() {
|
|||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
|
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
|
||||||
{webhook.enabled ? 'Enabled' : 'Disabled'}
|
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
<Trans>
|
||||||
Listening to{' '}
|
Listening to{' '}
|
||||||
{webhook.eventTriggers
|
{webhook.eventTriggers
|
||||||
.map((trigger) => toFriendlyWebhookEventName(trigger))
|
.map((trigger) => toFriendlyWebhookEventName(trigger))
|
||||||
.join(', ')}
|
.join(', ')}
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
<Trans>
|
||||||
Created on{' '}
|
Created on{' '}
|
||||||
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
|
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
|
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
|
<Link href={`/settings/webhooks/${webhook.id}`}>
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<DeleteWebhookDialog webhook={webhook}>
|
<DeleteWebhookDialog webhook={webhook}>
|
||||||
<Button variant="destructive">Delete</Button>
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
</DeleteWebhookDialog>
|
</DeleteWebhookDialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
SKIP_QUERY_BATCH_META,
|
SKIP_QUERY_BATCH_META,
|
||||||
@ -42,7 +45,9 @@ export const EditTemplateForm = ({
|
|||||||
isEnterprise,
|
isEnterprise,
|
||||||
templateRootPath,
|
templateRootPath,
|
||||||
}: EditTemplateFormProps) => {
|
}: EditTemplateFormProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
@ -68,18 +73,18 @@ export const EditTemplateForm = ({
|
|||||||
|
|
||||||
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
|
||||||
settings: {
|
settings: {
|
||||||
title: 'General',
|
title: msg`General`,
|
||||||
description: 'Configure general settings for the template.',
|
description: msg`Configure general settings for the template.`,
|
||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
},
|
},
|
||||||
signers: {
|
signers: {
|
||||||
title: 'Add Placeholders',
|
title: msg`Add Placeholders`,
|
||||||
description: 'Add all relevant placeholders for each recipient.',
|
description: msg`Add all relevant placeholders for each recipient.`,
|
||||||
stepIndex: 2,
|
stepIndex: 2,
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add Fields',
|
title: msg`Add Fields`,
|
||||||
description: 'Add all relevant fields for each recipient.',
|
description: msg`Add all relevant fields for each recipient.`,
|
||||||
stepIndex: 3,
|
stepIndex: 3,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -144,8 +149,8 @@ export const EditTemplateForm = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while updating the document settings.',
|
description: _(msg`An error occurred while updating the document settings.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -167,8 +172,8 @@ export const EditTemplateForm = ({
|
|||||||
setStep('fields');
|
setStep('fields');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while adding signers.',
|
description: _(msg`An error occurred while adding signers.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -190,8 +195,8 @@ export const EditTemplateForm = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Template saved',
|
title: _(msg`Template saved`),
|
||||||
description: 'Your templates has been saved successfully.',
|
description: _(msg`Your templates has been saved successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -201,8 +206,8 @@ export const EditTemplateForm = ({
|
|||||||
router.push(templateRootPath);
|
router.push(templateRootPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while adding signers.',
|
description: _(msg`An error occurred while adding signers.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import type { TemplatePageViewProps } from './template-page-view';
|
import type { TemplatePageViewProps } from './template-page-view';
|
||||||
import { TemplatePageView } from './template-page-view';
|
import { TemplatePageView } from './template-page-view';
|
||||||
|
|
||||||
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
|
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
|
||||||
|
|
||||||
export default function TemplatePage({ params }: TemplatePageProps) {
|
export default function TemplatePage({ params }: TemplatePageProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return <TemplatePageView params={params} />;
|
return <TemplatePageView params={params} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { LinkIcon } from 'lucide-react';
|
import { LinkIcon } from 'lucide-react';
|
||||||
|
|
||||||
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
||||||
@ -27,7 +28,12 @@ export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewPr
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
{template.directLink ? 'Manage' : 'Create'} Direct Link
|
|
||||||
|
{template.directLink ? (
|
||||||
|
<Trans>Manage Direct Link</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Create Direct Link</Trans>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<TemplateDirectLinkDialog
|
<TemplateDirectLinkDialog
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import React from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
@ -56,7 +57,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
<div>
|
<div>
|
||||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Templates
|
<Trans>Templates</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
@ -58,7 +59,7 @@ export const DataTableActionDropdown = ({
|
|||||||
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||||
<Link href={`${templateRootPath}/${row.id}`}>
|
<Link href={`${templateRootPath}/${row.id}`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
@ -67,18 +68,18 @@ export const DataTableActionDropdown = ({
|
|||||||
onClick={() => setDuplicateDialogOpen(true)}
|
onClick={() => setDuplicateDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Duplicate
|
<Trans>Duplicate</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setTemplateDirectLinkDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setTemplateDirectLinkDialogOpen(true)}>
|
||||||
<Share2Icon className="mr-2 h-4 w-4" />
|
<Share2Icon className="mr-2 h-4 w-4" />
|
||||||
Direct link
|
<Trans>Direct link</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{!teamId && (
|
{!teamId && (
|
||||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||||
<MoveRight className="mr-2 h-4 w-4" />
|
<MoveRight className="mr-2 h-4 w-4" />
|
||||||
Move to Team
|
<Trans>Move to Team</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -87,7 +88,7 @@ export const DataTableActionDropdown = ({
|
|||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
<Trans>Delete</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTransition } from 'react';
|
import { useMemo, useTransition } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
|
import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
@ -45,47 +48,24 @@ export const TemplatesDataTable = ({
|
|||||||
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
const columns = useMemo(() => {
|
||||||
startTransition(() => {
|
return [
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
{remaining.documents === 0 && (
|
|
||||||
<Alert variant="warning" className="mb-4">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Document Limit Exceeded!</AlertTitle>
|
|
||||||
<AlertDescription className="mt-2">
|
|
||||||
You have reached your document limit.{' '}
|
|
||||||
<Link className="underline underline-offset-4" href="/settings/billing">
|
|
||||||
Upgrade your account to continue!
|
|
||||||
</Link>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{
|
{
|
||||||
header: 'Created',
|
header: _(msg`Created`),
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: _(msg`Title`),
|
||||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: () => (
|
header: () => (
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
Type
|
<Trans>Type</Trans>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
@ -96,36 +76,45 @@ export const TemplatesDataTable = ({
|
|||||||
<li>
|
<li>
|
||||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||||
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
|
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
|
||||||
Public
|
<Trans>Public</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Public templates are connected to your public profile. Any modifications
|
<Trans>
|
||||||
to public templates will also appear in your public profile.
|
Public templates are connected to your public profile. Any modifications to
|
||||||
|
public templates will also appear in your public profile.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<div className="mb-2 flex w-fit flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600">
|
<div className="mb-2 flex w-fit flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600">
|
||||||
<Link2Icon className="mr-1 h-3 w-3" />
|
<Link2Icon className="mr-1 h-3 w-3" />
|
||||||
direct link
|
<Trans>direct link</Trans>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Direct link templates contain one dynamic recipient placeholder. Anyone
|
<Trans>
|
||||||
with access to this link can sign the document, and it will then appear on
|
Direct link templates contain one dynamic recipient placeholder. Anyone with
|
||||||
your documents page.
|
access to this link can sign the document, and it will then appear on your
|
||||||
|
documents page.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||||
<LockIcon className="mr-2 h-5 w-5 text-blue-600 dark:text-blue-300" />
|
<LockIcon className="mr-2 h-5 w-5 text-blue-600 dark:text-blue-300" />
|
||||||
{teamId ? 'Team Only' : 'Private'}
|
{teamId ? <Trans>Team Only</Trans> : <Trans>Private</Trans>}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{teamId
|
{teamId ? (
|
||||||
? 'Team only templates are not linked anywhere and are visible only to your team.'
|
<Trans>
|
||||||
: 'Private templates can only be modified and viewed by you.'}
|
Team only templates are not linked anywhere and are visible only to your
|
||||||
|
team.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Private templates can only be modified and viewed by you.</Trans>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -149,7 +138,7 @@ export const TemplatesDataTable = ({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Actions',
|
header: _(msg`Actions`),
|
||||||
accessorKey: 'actions',
|
accessorKey: 'actions',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
@ -169,7 +158,39 @@ export const TemplatesDataTable = ({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
] satisfies DataTableColumnDef<(typeof templates)[number]>[];
|
||||||
|
}, [documentRootPath, teamId, templateRootPath]);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
startTransition(() => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{remaining.documents === 0 && (
|
||||||
|
<Alert variant="warning" className="mb-4">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Document Limit Exceeded!</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription className="mt-2">
|
||||||
|
<Trans>
|
||||||
|
You have reached your document limit.{' '}
|
||||||
|
<Link className="underline underline-offset-4" href="/settings/billing">
|
||||||
|
Upgrade your account to continue!
|
||||||
|
</Link>
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
data={templates}
|
data={templates}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -27,6 +30,7 @@ export const DeleteTemplateDialog = ({
|
|||||||
}: DeleteTemplateDialogProps) => {
|
}: DeleteTemplateDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({
|
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({
|
||||||
@ -34,8 +38,8 @@ export const DeleteTemplateDialog = ({
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Template deleted',
|
title: _(msg`Template deleted`),
|
||||||
description: 'Your template has been successfully deleted.',
|
description: _(msg`Your template has been successfully deleted.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -43,8 +47,8 @@ export const DeleteTemplateDialog = ({
|
|||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'This template could not be deleted at this time. Please try again.',
|
description: _(msg`This template could not be deleted at this time. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -55,11 +59,15 @@ export const DeleteTemplateDialog = ({
|
|||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Do you want to delete this template?</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Do you want to delete this template?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
Please note that this action is irreversible. Once confirmed, your template will be
|
Please note that this action is irreversible. Once confirmed, your template will be
|
||||||
permanently deleted.
|
permanently deleted.
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -70,7 +78,7 @@ export const DeleteTemplateDialog = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -79,7 +87,7 @@ export const DeleteTemplateDialog = ({
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onClick={async () => deleteTemplate({ id, teamId })}
|
onClick={async () => deleteTemplate({ id, teamId })}
|
||||||
>
|
>
|
||||||
Delete
|
<Trans>Delete</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -27,6 +30,7 @@ export const DuplicateTemplateDialog = ({
|
|||||||
}: DuplicateTemplateDialogProps) => {
|
}: DuplicateTemplateDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: duplicateTemplate, isLoading } =
|
const { mutateAsync: duplicateTemplate, isLoading } =
|
||||||
@ -35,8 +39,8 @@ export const DuplicateTemplateDialog = ({
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Template duplicated',
|
title: _(msg`Template duplicated`),
|
||||||
description: 'Your template has been duplicated successfully.',
|
description: _(msg`Your template has been duplicated successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -44,8 +48,8 @@ export const DuplicateTemplateDialog = ({
|
|||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while duplicating template.',
|
description: _(msg`An error occurred while duplicating template.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -55,9 +59,13 @@ export const DuplicateTemplateDialog = ({
|
|||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Do you want to duplicate this template?</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Do you want to duplicate this template?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription className="pt-2">Your template will be duplicated.</DialogDescription>
|
<DialogDescription className="pt-2">
|
||||||
|
<Trans>Your template will be duplicated.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@ -67,7 +75,7 @@ export const DuplicateTemplateDialog = ({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -80,7 +88,7 @@ export const DuplicateTemplateDialog = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Duplicate
|
<Trans>Duplicate</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { Bird } from 'lucide-react';
|
import { Bird } from 'lucide-react';
|
||||||
|
|
||||||
export const EmptyTemplateState = () => {
|
export const EmptyTemplateState = () => {
|
||||||
@ -6,10 +7,14 @@ export const EmptyTemplateState = () => {
|
|||||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold">We're all empty</h3>
|
<h3 className="text-lg font-semibold">
|
||||||
|
<Trans>We're all empty</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<p className="mt-2 max-w-[50ch]">
|
<p className="mt-2 max-w-[50ch]">
|
||||||
|
<Trans>
|
||||||
You have not yet created any templates. To create a template please upload one.
|
You have not yet created any templates. To create a template please upload one.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
@ -31,7 +34,10 @@ type MoveTemplateDialogProps = {
|
|||||||
|
|
||||||
export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTemplateDialogProps) => {
|
export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTemplateDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||||
@ -39,16 +45,16 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
toast({
|
toast({
|
||||||
title: 'Template moved',
|
title: _(msg`Template moved`),
|
||||||
description: 'The template has been successfully moved to the selected team.',
|
description: _(msg`The template has been successfully moved to the selected team.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: error.message || 'An error occurred while moving the template.',
|
description: error.message || _(msg`An error occurred while moving the template.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -67,20 +73,22 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Move Template to Team</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Move Template to Team</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Select a team to move this template to. This action cannot be undone.
|
<Trans>Select a team to move this template to. This action cannot be undone.</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a team" />
|
<SelectValue placeholder={_(msg`Select a team`)} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{isLoadingTeams ? (
|
{isLoadingTeams ? (
|
||||||
<SelectItem value="loading" disabled>
|
<SelectItem value="loading" disabled>
|
||||||
Loading teams...
|
<Trans>Loading teams...</Trans>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
teams?.map((team) => (
|
teams?.map((team) => (
|
||||||
@ -108,10 +116,10 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}>
|
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}>
|
||||||
{isLoading ? 'Moving...' : 'Move'}
|
{isLoading ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import React, { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { FilePlus, Loader } from 'lucide-react';
|
import { FilePlus, Loader } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
@ -34,6 +36,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||||
|
|
||||||
@ -61,9 +64,10 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Template document uploaded',
|
title: _(msg`Template document uploaded`),
|
||||||
description:
|
description: _(
|
||||||
'Your document has been uploaded successfully. You will be redirected to the template page.',
|
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
|
||||||
|
),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -72,8 +76,8 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
router.push(`${templateRootPath}/${id}`);
|
router.push(`${templateRootPath}/${id}`);
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'Please try again later.',
|
description: _(msg`Please try again later.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -89,15 +93,20 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
|
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
|
||||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
New Template
|
<Trans>New Template</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent className="w-full max-w-xl">
|
<DialogContent className="w-full max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New Template</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>New Template</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Templates allow you to quickly generate documents with pre-filled recipients and fields.
|
<Trans>
|
||||||
|
Templates allow you to quickly generate documents with pre-filled recipients and
|
||||||
|
fields.
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -114,7 +123,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="button" variant="secondary" disabled={isUploadingFile}>
|
<Button type="button" variant="secondary" disabled={isUploadingFile}>
|
||||||
Close
|
<Trans>Close</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { TemplatesPageView } from './templates-page-view';
|
import { TemplatesPageView } from './templates-page-view';
|
||||||
import type { TemplatesPageViewProps } from './templates-page-view';
|
import type { TemplatesPageViewProps } from './templates-page-view';
|
||||||
|
|
||||||
@ -14,5 +16,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return <TemplatesPageView searchParams={searchParams} />;
|
return <TemplatesPageView searchParams={searchParams} />;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user