Compare commits
62 Commits
feat/organ
...
v1.5.5-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87423e240a | ||
|
|
9298213177 | ||
|
|
4b90adde6b | ||
|
|
fceb0eaac9 | ||
|
|
bd40e63392 | ||
|
|
6e09a4700b | ||
|
|
f8ddb0f922 | ||
|
|
96e4797cdd | ||
|
|
3d3c53db02 | ||
|
|
8fe67e167c | ||
|
|
18b39eb538 | ||
|
|
3bc9b5ada0 | ||
|
|
1126fe4bff | ||
|
|
db9899d293 | ||
|
|
0eeccfd643 | ||
|
|
aa4b6f1723 | ||
|
|
c8a09099a3 | ||
|
|
0f87dc047b | ||
|
|
80c758fb62 | ||
|
|
7705dbae0c | ||
|
|
8b58f10cbe | ||
|
|
fe1f0e6a76 | ||
|
|
a82975fd78 | ||
|
|
a4967f19e8 | ||
|
|
a311869c9b | ||
|
|
6f3cea52e8 | ||
|
|
732827f81d | ||
|
|
bfff1234bb | ||
|
|
93a149d637 | ||
|
|
f7ae3104ea | ||
|
|
64870f22b9 | ||
|
|
12e4bc918d | ||
|
|
e36763a85d | ||
|
|
0bc9c590a7 | ||
|
|
4d4dfd3c5f | ||
|
|
c9b4915fc8 | ||
|
|
110f9bae12 | ||
|
|
6285ef2cc0 | ||
|
|
e2987b3ef1 | ||
|
|
d97ab04d57 | ||
|
|
665c943d8f | ||
|
|
8fe6533ef5 | ||
|
|
fd170f095b | ||
|
|
1400c335a5 | ||
|
|
03bf16522d | ||
|
|
627265f016 | ||
|
|
08b693ff95 | ||
|
|
97ce3530e0 | ||
|
|
33f3565715 | ||
|
|
950a697115 | ||
|
|
fc70f78e61 | ||
|
|
aa52316ee3 | ||
|
|
ea64ccae29 | ||
|
|
b87154001a | ||
|
|
d4a7eb299e | ||
|
|
2ef619226e | ||
|
|
65c07032de | ||
|
|
b436331d7d | ||
|
|
81ab220f1e | ||
|
|
cc60437dcd | ||
|
|
171b8008f8 | ||
|
|
5c00b82894 |
10
.env.example
10
.env.example
@@ -40,16 +40,6 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
|
||||
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
|
||||
|
||||
# [[SIGNING]]
|
||||
# OPTIONAL: Defines the signing transport to use. Available options: local (default)
|
||||
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
|
||||
# OPTIONAL: Defines the passphrase for the signing certificate.
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE=
|
||||
# OPTIONAL: Defines the file contents for the signing certificate as a base64 encoded string.
|
||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS=
|
||||
# OPTIONAL: Defines the file path for the signing certificate. defaults to ./example/cert.p12
|
||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
|
||||
|
||||
# [[STORAGE]]
|
||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||
|
||||
@@ -82,7 +82,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
||||
- [NextAuth.js](https://next-auth.js.org/) - Authentication
|
||||
- [react-email](https://react.email/) - Email Templates
|
||||
- [tRPC](https://trpc.io/) - API
|
||||
- [Node SignPDF](https://github.com/vbuch/node-signpdf) - Digital Signature
|
||||
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
|
||||
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||
- [Stripe](https://stripe.com/) - Payments
|
||||
|
||||
@@ -17,7 +17,8 @@ For the digital signature of your documents you need a signing certificate in .p
|
||||
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
||||
|
||||
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
|
||||
5. Place the certificate `/apps/web/resources/certificate.p12`
|
||||
|
||||
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Building Documenso — Part 1: Certificates'
|
||||
description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life.
|
||||
description: Let's take a look why you need a signing certificate and how Documenso does it.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
|
||||
117
apps/marketing/content/blog/building-documenso-pt2.mdx
Normal file
117
apps/marketing/content/blog/building-documenso-pt2.mdx
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
title: 'Building Documenso — Part 2: Signature Validity'
|
||||
description: Is a signature valid? And what does that mean? It's a surprisingly complex question; let's take a look.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-04-05
|
||||
tags:
|
||||
- Document Signature
|
||||
- Certificates
|
||||
- Signing
|
||||
---
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/eu-validate-1.png"
|
||||
width= "650"
|
||||
height= "650"
|
||||
alt= "A report card for signature validity."
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
If a tree does not comply with the EU trust list, does it make a sound when validating?r
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
> TLDR; Signatures can be valid and compliant for different signature levels, even if some validators show higher-level errors. Not all helpful security measures are mandated by law.
|
||||
|
||||
# A valid question
|
||||
|
||||
A few days ago, an early adopter brought up this question in our [Discord](https://documen.so/discord):
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/eu-validate-2.png"
|
||||
width= "650"
|
||||
height= "650"
|
||||
alt= "A report card for signature validity."
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
You can check out the validator here: [https://documen.so/eu-validator](https://documen.so/eu-validator)
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
For those unfamiliar with the tool, he used the validator tool of the EU's Digital Signature Service (DSS) Framework to check the signature of a document signed with Documenso. The EU provides this tool to help users and providers check the validity level of their signatures.
|
||||
|
||||
A short refresher from [Building Documenso — Part 1: Certificates](https://documen.so/certs):
|
||||
|
||||
> Documenso inserts all visual signatures into the document and then seals it using the "Documenso Inc." corporate certificate. This makes the resulting PDF document tamper-proof and guarantees it hasn't changed since signing.
|
||||
|
||||
Before we answer if the document was signed correctly, we need to understand what the goal was.
|
||||
|
||||
There are three signature levels in the European eIDAS regulation:
|
||||
|
||||
1. **Simple Electronic Signatures (Level 1/ SES):** This is just a visual signature or even a checkbox on a document.
|
||||
|
||||
2. **Advanded Electronic Signatures (Level 2/ AES)**: An actual crypographic signature (not just a seal on the whole document, but a specific signature), using a certificate linked to the identification data of the signer.
|
||||
|
||||
3. **Qualified Electronic Signatures (Level 3/ QES):** Same as 2. but done by a government-certified entity on certified hardware and after identifying the signer with an official ID document (e.g., passport)
|
||||
|
||||
> 💡 Side Note: Number 2 (AES) is how most people imagine digital signatures. But most of the market uses 1. plus a seal on the whole document under the name of the signing provider (e.g., Documenso). The signer's data is only inserted visually, not in the actual signature. Why? One of the reasons is that it's much easier, and without a readily available open source framework to draw from, it is quite tricky to build. This is something we aim to build (which many have done) and open source (which no one has done).
|
||||
|
||||
From the perspective of eIDAS, Documenso offers Level 1/ SES signatures since it does not adhere to all of the requirements of Level 2/ AES. This means that, technically, there is no legal need to seal the document to achieve this level of validity (at least within eIDAS). We do it anyway since it improves the level of confidence users can have in the signed document. Sealing the document, even though not legally required, is a great example of Documenso's approach to signatures. First, we aim to provide all legal requirements for a given use case. Then, we add any protection that can be added without unwarranted friction to the creation of the signature.
|
||||
|
||||
## Not if valid, but how valid
|
||||
|
||||
**Q: So, is the signature in the image valid?**
|
||||
|
||||
A: Yes, as an eidas Level 1 SES.
|
||||
|
||||
**Q: Then why does it say "Unable to build a certificate chain up to a trusted list"**
|
||||
|
||||
A: The certificate we use to seal the document after inserting the signatures is not on the EU Trust list.
|
||||
|
||||
**Q: Does that mean it is less secure?**
|
||||
|
||||
A: No, it means the provider (Wisekey) is not on a list maintained by the EU. The cryptographic signature is just as strong as any other
|
||||
|
||||
For someone who does not deal with this stuff daily, this can be hard to comprehend. Whether you use a certificate you generated yourself, one generated by a certificate authority (CA) like Wisekey, or one by another on the EU trust list (e.g., Bundesdruckerei), the cryptographic security guaranteeing that the document has not been tampered with is always the same. Many providers like Documenso, DocuSign, PandaDoc, and Digisigner all use this method for their regular plans. That means if you were to run a document signed by them through the validator above, the result would be the same[1]. The interesting question is why? Why do it like this?
|
||||
|
||||
## Certificate Infrastructure is broken
|
||||
|
||||
While there are some actual expenses involved in providing AES and QES, the blunt reality is that it's just good business to charge for them per signature, making it unsuitable for the "standard offerings"; almost no one has the resources to set this up themselves. While this initial process of becoming a QES-certified entity is really expensive, selling the certificates afterward is very lucrative. This leads to less innovation in the space and only big players providing these high-compliance services. Even certificates only used to seal documents without being QES certified are sold for a large range of prices, and they cost almost nothing to produce.
|
||||
|
||||
## Why Though?
|
||||
|
||||
**Q: Why do people buy a certificate for money and not just generate one themselves? Isn't the cryptographic security the same?**
|
||||
|
||||
A: Self-generated certificates are not recognized for higher-level compliance signatures like QES
|
||||
|
||||
**Q: So if you don't need higher-level signatures, you could just generate one yourself?**
|
||||
|
||||
A: Yes, you could. Since eIDAS Level 1 does not require a cert, you could use your own.
|
||||
|
||||
**Q: Why don't more people?**
|
||||
|
||||
A: One reason is that apart from the EU trust list, there are others, like the Adobe trust list. While not legally required, being on that one (like Wisekey) gives you a green checkmark in Adobe PDF, which is how most people check signature validity.
|
||||
|
||||
**Q: Not a question, but all of this sounds weird**
|
||||
|
||||
A: It is. This is one of the reasons why Documenso exists. We plan to make this easier.
|
||||
|
||||
**Q: How?**
|
||||
|
||||
A: By explaining and providing easy-to-use tools and eventually free, highly compliant signature certificates for everyone.
|
||||
|
||||
Eventually, we plan to start a free certificate authority called Let's Sign, named after another instituion that broke the paid certificate paradigm to the benefit of the internet: [Let's Encrypt](https://letsencrypt.org/).
|
||||
|
||||
As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments.
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
\
|
||||
\
|
||||
\
|
||||
[1] The signature format (e.g. PKCS7-B) will vary. It's the format what the signature inserted into the document looks like. eIDAS itself does not specifically require any given format, but the PAdES defined by the EU is mostly used by european providers.
|
||||
@@ -22,7 +22,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||
const config = {
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@openstatus/react": "^0.0.3",
|
||||
"contentlayer": "^0.3.4",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.279.0",
|
||||
|
||||
BIN
apps/marketing/public/blog/eu-validate-1.png
Normal file
BIN
apps/marketing/public/blog/eu-validate-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
BIN
apps/marketing/public/blog/eu-validate-2.png
Normal file
BIN
apps/marketing/public/blog/eu-validate-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
@@ -1,4 +1,5 @@
|
||||
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
|
||||
import type { TClaimPlanRequestSchema } from './types';
|
||||
import { ZClaimPlanResponseSchema } from './types';
|
||||
|
||||
export const claimPlan = async ({
|
||||
name,
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -48,16 +46,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
})}
|
||||
>
|
||||
{showProfilesAnnouncementBar && (
|
||||
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
|
||||
<div className="absolute inset-0 -z-[1]">
|
||||
<Image
|
||||
src={launchWeekTwoImage}
|
||||
className="h-full w-full object-cover"
|
||||
alt="Launch Week 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-background text-center text-sm text-white">
|
||||
<div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5">
|
||||
<div className="text-black text-center text-sm font-medium">
|
||||
Claim your documenso public profile username now!{' '}
|
||||
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
|
||||
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
|
||||
|
||||
@@ -55,6 +55,7 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
|
||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||
/>
|
||||
<Bar
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
dataKey={metricKey as string}
|
||||
maxBarSize={60}
|
||||
fill="hsl(var(--primary))"
|
||||
|
||||
@@ -13,6 +13,7 @@ export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
||||
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
|
||||
const formattedData = data.map((item) => ({
|
||||
amount: Number(item.amount),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
date: formatMonth(item.date as string),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import {
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Variants, motion } from 'framer-motion';
|
||||
import type { Variants } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { TOSSFriendsSchema } from './schema';
|
||||
import type { TOSSFriendsSchema } from './schema';
|
||||
|
||||
const ContainerVariants: Variants = {
|
||||
initial: {
|
||||
|
||||
@@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
|
||||
expired: null,
|
||||
signedAt: null,
|
||||
readStatus: 'OPENED',
|
||||
documentDeletedAt: null,
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
role: 'SIGNER',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import LogoImage from '@documenso/assets/logo.png';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
|
||||
// import { StatusWidgetContainer } from './status-widget-container';
|
||||
|
||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
@@ -62,6 +64,10 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* <div className="mt-6">
|
||||
<StatusWidgetContainer />
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
||||
|
||||
@@ -96,7 +96,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]"
|
||||
className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]"
|
||||
>
|
||||
Document signing,
|
||||
<span className="block" /> finally open source.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// https://github.com/documenso/documenso/pull/1044/files#r1538258462
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { StatusWidget } from './status-widget';
|
||||
|
||||
export function StatusWidgetContainer() {
|
||||
return (
|
||||
<Suspense fallback={<StatusWidgetFallback />}>
|
||||
<StatusWidget slug="documenso-status" />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusWidgetFallback() {
|
||||
return (
|
||||
<div className="border-border inline-flex max-w-fit items-center justify-between space-x-2 rounded-md border border-gray-200 px-2 py-2 pr-3 text-sm">
|
||||
<span className="bg-muted h-2 w-36 animate-pulse rounded-md" />
|
||||
<span className="bg-muted relative inline-flex h-2 w-2 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
apps/marketing/src/components/(marketing)/status-widget.tsx
Normal file
73
apps/marketing/src/components/(marketing)/status-widget.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { memo, use } from 'react';
|
||||
|
||||
import { type Status, getStatus } from '@openstatus/react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
const getStatusLevel = (level: Status) => {
|
||||
return {
|
||||
operational: {
|
||||
label: 'Operational',
|
||||
color: 'bg-green-500',
|
||||
color2: 'bg-green-400',
|
||||
},
|
||||
degraded_performance: {
|
||||
label: 'Degraded Performance',
|
||||
color: 'bg-yellow-500',
|
||||
color2: 'bg-yellow-400',
|
||||
},
|
||||
partial_outage: {
|
||||
label: 'Partial Outage',
|
||||
color: 'bg-yellow-500',
|
||||
color2: 'bg-yellow-400',
|
||||
},
|
||||
major_outage: {
|
||||
label: 'Major Outage',
|
||||
color: 'bg-red-500',
|
||||
color2: 'bg-red-400',
|
||||
},
|
||||
unknown: {
|
||||
label: 'Unknown',
|
||||
color: 'bg-gray-500',
|
||||
color2: 'bg-gray-400',
|
||||
},
|
||||
incident: {
|
||||
label: 'Incident',
|
||||
color: 'bg-yellow-500',
|
||||
color2: 'bg-yellow-400',
|
||||
},
|
||||
under_maintenance: {
|
||||
label: 'Under Maintenance',
|
||||
color: 'bg-gray-500',
|
||||
color2: 'bg-gray-400',
|
||||
},
|
||||
}[level];
|
||||
};
|
||||
|
||||
export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) {
|
||||
const { status } = use(getStatus(slug));
|
||||
const level = getStatusLevel(status);
|
||||
|
||||
return (
|
||||
<a
|
||||
className="border-border inline-flex max-w-fit items-center justify-between gap-2 space-x-2 rounded-md border border-gray-200 px-3 py-1 text-sm"
|
||||
href="https://status.documenso.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm">{level.label}</p>
|
||||
</div>
|
||||
|
||||
<span className="relative ml-auto flex h-1.5 w-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
|
||||
level.color2,
|
||||
)}
|
||||
/>
|
||||
<span className={cn('relative inline-flex h-1.5 w-1.5 rounded-full', level.color)} />
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
@@ -346,7 +346,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
{signatureText && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]',
|
||||
'text-foreground truncate text-4xl font-semibold [font-family:var(--font-caveat)]',
|
||||
)}
|
||||
>
|
||||
{signatureText}
|
||||
@@ -360,7 +360,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
>
|
||||
<Input
|
||||
id="signatureText"
|
||||
className="text-foreground placeholder:text-muted-foreground border-none p-0 text-sm focus-visible:ring-0"
|
||||
className="text-foreground placeholder:text-muted-foreground truncate border-none p-0 text-sm focus-visible:ring-0"
|
||||
placeholder="Draw or type name here"
|
||||
disabled={isSubmitting}
|
||||
{...register('signatureText', {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { FieldError } from 'react-hook-form';
|
||||
import type { FieldError } from 'react-hook-form';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SVGAttributes } from 'react';
|
||||
import type { SVGAttributes } from 'react';
|
||||
|
||||
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import { ThemeProviderProps } from 'next-themes/dist/types';
|
||||
import type { ThemeProviderProps } from 'next-themes/dist/types';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
|
||||
@@ -23,7 +23,7 @@ const config = {
|
||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"next-axiom": "^1.1.1",
|
||||
"next-plausible": "^3.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"posthog-js": "^1.75.3",
|
||||
"posthog-node": "^3.1.1",
|
||||
@@ -59,6 +60,7 @@
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/node": "20.1.0",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
|
||||
@@ -4,13 +4,22 @@ import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
ScrollTextIcon,
|
||||
Share,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
@@ -32,7 +41,7 @@ export type DocumentPageViewDropdownProps = {
|
||||
Recipient: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null };
|
||||
};
|
||||
|
||||
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
||||
@@ -50,9 +59,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
|
||||
const isOwner = document.User.id === session.user.id;
|
||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||
const isDeleted = document.deletedAt !== null;
|
||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
||||
const isDocumentDeletable = isOwner;
|
||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
@@ -106,12 +116,22 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`${documentsPath}/${document.id}/logs`}>
|
||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||
Audit Log
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -138,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
{isDocumentDeletable && (
|
||||
<DeleteDocumentDialog
|
||||
id={document.id}
|
||||
status={document.status}
|
||||
documentTitle={document.title}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<DeleteDocumentDialog
|
||||
id={document.id}
|
||||
status={document.status}
|
||||
documentTitle={document.title}
|
||||
open={isDeleteDialogOpen}
|
||||
canManageDocument={canManageDocument}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
|
||||
{isDuplicateDialogOpen && (
|
||||
<DuplicateDocumentDialog
|
||||
id={document.id}
|
||||
|
||||
@@ -12,7 +12,8 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import type { Team, TeamEmail } from '@documenso/prisma/client';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
@@ -34,7 +35,7 @@ export type DocumentPageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
team?: Team & { teamEmail: TeamEmail | null };
|
||||
};
|
||||
|
||||
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||
@@ -118,11 +119,17 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={recipients}
|
||||
documentStatus={document.status}
|
||||
position="bottom"
|
||||
>
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -92,7 +92,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={recipients}
|
||||
documentStatus={document.status}
|
||||
position="bottom"
|
||||
>
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
@@ -100,7 +104,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
</div>
|
||||
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
className="mt-6"
|
||||
initialDocument={document}
|
||||
documentRootPath={documentRootPath}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
|
||||
@@ -2,6 +2,8 @@ import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft, Loader } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
@@ -13,7 +15,12 @@ export default function Loading() {
|
||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
Loading Document...
|
||||
</h1>
|
||||
<div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||
|
||||
<div className="flex h-10 items-center">
|
||||
<Skeleton className="my-6 h-4 w-24 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, DownloadIcon } from 'lucide-react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Recipient, Team } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { FRIENDLY_STATUS_MAP } from '~/components/formatter/document-status';
|
||||
import {
|
||||
DocumentStatus as DocumentStatusComponent,
|
||||
FRIENDLY_STATUS_MAP,
|
||||
} from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentLogsDataTable } from './document-logs-data-table';
|
||||
import { DownloadAuditLogButton } from './download-audit-log-button';
|
||||
import { DownloadCertificateButton } from './download-certificate-button';
|
||||
|
||||
export type DocumentLogsPageViewProps = {
|
||||
params: {
|
||||
@@ -23,6 +29,8 @@ export type DocumentLogsPageViewProps = {
|
||||
};
|
||||
|
||||
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
||||
const locale = getLocale();
|
||||
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
@@ -67,15 +75,21 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
},
|
||||
{
|
||||
description: 'Created by',
|
||||
value: document.User.name ?? document.User.email,
|
||||
value: document.User.name
|
||||
? `${document.User.name} (${document.User.email})`
|
||||
: document.User.email,
|
||||
},
|
||||
{
|
||||
description: 'Date created',
|
||||
value: document.createdAt.toISOString(),
|
||||
value: DateTime.fromJSDate(document.createdAt)
|
||||
.setLocale(locale)
|
||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||
},
|
||||
{
|
||||
description: 'Last updated',
|
||||
value: document.updatedAt.toISOString(),
|
||||
value: DateTime.fromJSDate(document.updatedAt)
|
||||
.setLocale(locale)
|
||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||
},
|
||||
{
|
||||
description: 'Time zone',
|
||||
@@ -90,7 +104,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
text = `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
return `${text} - ${recipient.role}`;
|
||||
return `[${recipient.role}] ${text}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -104,20 +118,24 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
<div>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatusComponent
|
||||
inheritColor
|
||||
status={document.status}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||
<Button variant="outline" className="mr-2 w-full sm:w-auto">
|
||||
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
||||
Download certificate
|
||||
</Button>
|
||||
<DownloadCertificateButton className="mr-2" documentId={document.id} />
|
||||
|
||||
<Button className="w-full sm:w-auto">
|
||||
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
||||
Download PDF
|
||||
</Button>
|
||||
<DownloadAuditLogButton documentId={document.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DownloadAuditLogButtonProps = {
|
||||
className?: string;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: downloadAuditLogs, isLoading } =
|
||||
trpc.document.downloadAuditLogs.useMutation();
|
||||
|
||||
const onDownloadAuditLogsClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadAuditLogs({ documentId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
});
|
||||
|
||||
Object.assign(iframe.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
});
|
||||
|
||||
const onLoaded = () => {
|
||||
if (iframe.contentDocument?.readyState === 'complete') {
|
||||
iframe.contentWindow?.print();
|
||||
|
||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||
iframe.addEventListener('load', onLoaded);
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
onLoaded();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Sorry, we were unable to download the audit logs. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('w-full sm:w-auto', className)}
|
||||
loading={isLoading}
|
||||
onClick={() => void onDownloadAuditLogsClick()}
|
||||
>
|
||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||
Download Audit Logs
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DownloadCertificateButtonProps = {
|
||||
className?: string;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const DownloadCertificateButton = ({
|
||||
className,
|
||||
documentId,
|
||||
}: DownloadCertificateButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: downloadCertificate, isLoading } =
|
||||
trpc.document.downloadCertificate.useMutation();
|
||||
|
||||
const onDownloadCertificatesClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadCertificate({ documentId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
});
|
||||
|
||||
Object.assign(iframe.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
});
|
||||
|
||||
const onLoaded = () => {
|
||||
if (iframe.contentDocument?.readyState === 'complete') {
|
||||
iframe.contentWindow?.print();
|
||||
|
||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||
iframe.addEventListener('load', onLoaded);
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
onLoaded();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Sorry, we were unable to download the certificate. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('w-full sm:w-auto', className)}
|
||||
loading={isLoading}
|
||||
variant="outline"
|
||||
onClick={() => void onDownloadCertificatesClick()}
|
||||
>
|
||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||
Download Certificate
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
Pencil,
|
||||
Share,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
@@ -45,7 +44,7 @@ export type DataTableActionDropdownProps = {
|
||||
Recipient: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||
};
|
||||
|
||||
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||
@@ -67,8 +66,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
// const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isDocumentDeletable = isOwner;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
@@ -107,14 +106,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger data-testid="document-table-action-btn">
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
{recipient && recipient?.role !== RecipientRole.CC && (
|
||||
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
{recipient?.role === RecipientRole.VIEWER && (
|
||||
@@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
||||
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
@@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
{/* No point displaying this if there's no functionality. */}
|
||||
{/* <DropdownMenuItem disabled>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Void
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem> */}
|
||||
|
||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={Boolean(!canManageDocument && team?.teamEmail)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
{canManageDocument ? 'Delete' : 'Hide'}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||
@@ -186,16 +189,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
{isDocumentDeletable && (
|
||||
<DeleteDocumentDialog
|
||||
id={row.id}
|
||||
status={row.status}
|
||||
documentTitle={row.title}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
)}
|
||||
<DeleteDocumentDialog
|
||||
id={row.id}
|
||||
status={row.status}
|
||||
documentTitle={row.title}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
teamId={team?.id}
|
||||
canManageDocument={canManageDocument}
|
||||
/>
|
||||
|
||||
{isDuplicateDialogOpen && (
|
||||
<DuplicateDocumentDialog
|
||||
id={row.id}
|
||||
|
||||
@@ -29,7 +29,7 @@ export type DocumentsDataTableProps = {
|
||||
}
|
||||
>;
|
||||
showSenderColumn?: boolean;
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||
};
|
||||
|
||||
export const DocumentsDataTable = ({
|
||||
@@ -76,7 +76,12 @@ export const DocumentsDataTable = ({
|
||||
{
|
||||
header: 'Recipient',
|
||||
accessorKey: 'recipient',
|
||||
cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />,
|
||||
cell: ({ row }) => (
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={row.original.Recipient}
|
||||
documentStatus={row.original.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
|
||||
@@ -2,8 +2,11 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = {
|
||||
status: DocumentStatus;
|
||||
documentTitle: string;
|
||||
teamId?: number;
|
||||
canManageDocument: boolean;
|
||||
};
|
||||
|
||||
export const DeleteDocumentDialog = ({
|
||||
@@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({
|
||||
status,
|
||||
documentTitle,
|
||||
teamId,
|
||||
canManageDocument,
|
||||
}: DeleteDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -83,47 +88,82 @@ export const DeleteDocumentDialog = ({
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Please note that this action is irreversible. Once confirmed, your document will be
|
||||
permanently deleted.
|
||||
You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
|
||||
<strong>"{documentTitle}"</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{status !== DocumentStatus.DRAFT && (
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
placeholder="Type 'delete' to confirm"
|
||||
/>
|
||||
</div>
|
||||
{canManageDocument ? (
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
{match(status)
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<AlertDescription>
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||
this document will be permanently deleted.
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(DocumentStatus.PENDING, () => (
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Please note that this action is <strong>irreversible</strong>.
|
||||
</p>
|
||||
|
||||
<p className="mt-1">Once confirmed, the following will occur:</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>Document will be permanently deleted</li>
|
||||
<li>Document signing process will be cancelled</li>
|
||||
<li>All inserted signatures will be voided</li>
|
||||
<li>All recipients will be notified</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<AlertDescription>
|
||||
<p>By deleting this document, the following will occur:</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>The document will be hidden from your account</li>
|
||||
<li>Recipients will still retain their copy of the document</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
<AlertDescription>
|
||||
Please contact support if you would like to revert this action.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status !== DocumentStatus.DRAFT && canManageDocument && (
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
placeholder="Type 'delete' to confirm"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
onClick={onDelete}
|
||||
disabled={!isDeleteEnabled}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
onClick={onDelete}
|
||||
disabled={!isDeleteEnabled && canManageDocument}
|
||||
variant="destructive"
|
||||
>
|
||||
{canManageDocument ? 'Delete' : 'Hide'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -41,7 +41,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 20;
|
||||
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
|
||||
const currentTeam = team ? { id: team.id, url: team.url } : undefined;
|
||||
const currentTeam = team
|
||||
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
|
||||
: undefined;
|
||||
|
||||
const getStatOptions: GetStatsInput = {
|
||||
user,
|
||||
|
||||
@@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
||||
<div
|
||||
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
|
||||
data-testid="empty-document-state"
|
||||
>
|
||||
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
<div className="text-center">
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { CreateOrganisationDialog } from '~/components/(organisations)/dialogs/create-organisation-dialog';
|
||||
import { CurrentUserOrganisationsDataTable } from '~/components/(organisations)/tables/current-user-organisations-data-table';
|
||||
|
||||
import { TeamInvitations } from './team-invitations';
|
||||
|
||||
export default function OrganisationsSettingsPage() {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Organisations"
|
||||
subtitle="Manage all organisations you are currently associated with."
|
||||
>
|
||||
{/* Todo: Org - only display when no org created & user can create org */}
|
||||
<CreateOrganisationDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
<CurrentUserOrganisationsDataTable />
|
||||
|
||||
<div className="mt-8">
|
||||
{/* Todo: Orgs */}
|
||||
<TeamInvitations />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,10 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -34,7 +37,6 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZCreateTemplateFormSchema = z.object({
|
||||
@@ -61,8 +63,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
||||
resolver: zodResolver(ZCreateTemplateFormSchema),
|
||||
});
|
||||
|
||||
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
|
||||
trpc.template.createTemplate.useMutation();
|
||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||
|
||||
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
|
||||
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
||||
@@ -140,6 +141,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
||||
useEffect(() => {
|
||||
if (!showNewTemplateDialog) {
|
||||
form.reset();
|
||||
setUploadedFile(null);
|
||||
}
|
||||
}, [form, showNewTemplateDialog]);
|
||||
|
||||
@@ -154,20 +156,23 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
||||
|
||||
<DialogContent className="w-full max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-4">New Template</DialogTitle>
|
||||
<DialogTitle>New Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Templates allow you to quickly generate documents with pre-filled recipients and fields.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="flex flex-col gap-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name your template</FormLabel>
|
||||
<FormLabel>Template name</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="email" type="text" className="bg-background mt-1.5" {...field} />
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
@@ -180,55 +185,57 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="template">Upload a Document</Label>
|
||||
<div className="mt-1.5">
|
||||
{uploadedFile ? (
|
||||
<Card gradient className="h-[40vh]">
|
||||
<CardContent className="flex h-full flex-col items-center justify-center p-2">
|
||||
<button
|
||||
onClick={() => resetForm()}
|
||||
title="Remove Template"
|
||||
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
<span className="sr-only">Remove Template</span>
|
||||
</button>
|
||||
|
||||
<div className="my-3">
|
||||
{uploadedFile ? (
|
||||
<Card gradient className="h-[40vh]">
|
||||
<CardContent className="flex h-full flex-col items-center justify-center p-2">
|
||||
<button
|
||||
onClick={() => resetForm()}
|
||||
title="Remove Template"
|
||||
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
<span className="sr-only">Remove Template</span>
|
||||
</button>
|
||||
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||
</div>
|
||||
|
||||
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||
</div>
|
||||
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
|
||||
Uploaded Document
|
||||
</p>
|
||||
|
||||
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
|
||||
Uploaded Document
|
||||
</p>
|
||||
|
||||
<span className="text-muted-foreground/80 mt-1 text-sm">
|
||||
{uploadedFile.file.name}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<DocumentDropzone
|
||||
className="mt-1.5 h-[40vh]"
|
||||
onDrop={onFileDrop}
|
||||
type="template"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground/80 mt-1 text-sm">
|
||||
{uploadedFile.file.name}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button loading={isCreatingTemplate} type="submit">
|
||||
Create Template
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={!uploadedFile}
|
||||
type="submit"
|
||||
>
|
||||
Create template
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
export type AuditLogDataTableProps = {
|
||||
logs: TDocumentAuditLog[];
|
||||
};
|
||||
|
||||
const dateFormat: DateTimeFormatOptions = {
|
||||
...DateTime.DATETIME_SHORT,
|
||||
hourCycle: 'h12',
|
||||
};
|
||||
|
||||
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
const parser = new UAParser();
|
||||
|
||||
const uppercaseFistLetter = (text: string) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Browser</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody className="print:text-xs">
|
||||
{logs.map((log, i) => (
|
||||
<TableRow className="break-inside-avoid" key={i}>
|
||||
<TableCell>
|
||||
<LocaleDate format={dateFormat} date={log.createdAt} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{log.name || log.email ? (
|
||||
<div>
|
||||
{log.name && (
|
||||
<p className="break-all" title={log.name}>
|
||||
{log.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{log.email && (
|
||||
<p className="text-muted-foreground break-all" title={log.email}>
|
||||
{log.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>N/A</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{log.ipAddress}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
139
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx
Normal file
139
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { AuditLogDataTable } from './data-table';
|
||||
|
||||
type AuditLogProps = {
|
||||
searchParams: {
|
||||
d: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
const { d } = searchParams;
|
||||
|
||||
if (typeof d !== 'string' || !d) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const rawDocumentId = decryptSecondaryData(d);
|
||||
|
||||
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const documentId = Number(rawDocumentId);
|
||||
|
||||
const document = await getEntireDocument({
|
||||
id: documentId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const { data: auditLogs } = await findDocumentAuditLogs({
|
||||
documentId: documentId,
|
||||
userId: document.userId,
|
||||
perPage: 100_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">Version History</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
|
||||
<p>
|
||||
<span className="font-medium">Document ID</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.id}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Enclosed Document</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.title}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Status</span>
|
||||
|
||||
<span className="mt-1 block">{document.deletedAt ? 'DELETED' : document.status}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Owner</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.User.name} ({document.User.email})
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Created At</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
<LocaleDate date={document.createdAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Last Updated</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
<LocaleDate date={document.updatedAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Time Zone</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.documentMeta?.timezone ?? 'N/A'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="font-medium">Recipients</p>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{document.Recipient.map((recipient) => (
|
||||
<li key={recipient.id}>
|
||||
<span className="text-muted-foreground">
|
||||
[{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}]
|
||||
</span>{' '}
|
||||
{recipient.name} ({recipient.email})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-8">
|
||||
<CardContent className="p-0">
|
||||
<AuditLogDataTable logs={auditLogs} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="my-8 flex-row-reverse">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<Logo className="max-h-6 print:max-h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
299
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx
Normal file
299
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_SIGNING_REASONS,
|
||||
} from '@documenso/lib/constants/recipient-roles';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
type SigningCertificateProps = {
|
||||
searchParams: {
|
||||
d: string;
|
||||
};
|
||||
};
|
||||
|
||||
const FRIENDLY_SIGNING_REASONS = {
|
||||
['__OWNER__']: 'I am the owner of this document',
|
||||
...RECIPIENT_ROLE_SIGNING_REASONS,
|
||||
};
|
||||
|
||||
export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
|
||||
const { d } = searchParams;
|
||||
|
||||
if (typeof d !== 'string' || !d) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const rawDocumentId = decryptSecondaryData(d);
|
||||
|
||||
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const documentId = Number(rawDocumentId);
|
||||
|
||||
const document = await getEntireDocument({
|
||||
id: documentId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const auditLogs = await getDocumentCertificateAuditLogs({
|
||||
id: documentId,
|
||||
});
|
||||
|
||||
const isOwner = (email: string) => {
|
||||
return email.toLowerCase() === document.User.email.toLowerCase();
|
||||
};
|
||||
|
||||
const getDevice = (userAgent?: string | null) => {
|
||||
if (!userAgent) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const parser = new UAParser(userAgent);
|
||||
|
||||
parser.setUA(userAgent);
|
||||
|
||||
const result = parser.getResult();
|
||||
|
||||
return `${result.os.name} - ${result.browser.name} ${result.browser.version}`;
|
||||
};
|
||||
|
||||
const getAuthenticationLevel = (recipientId: number) => {
|
||||
const recipient = document.Recipient.find((recipient) => recipient.id === recipientId);
|
||||
|
||||
if (!recipient) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const extractedAuthMethods = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
|
||||
.with('ACCOUNT', () => 'Account Re-Authentication')
|
||||
.with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
|
||||
.with('PASSKEY', () => 'Passkey Re-Authentication')
|
||||
.with('EXPLICIT_NONE', () => 'Email')
|
||||
.with(null, () => null)
|
||||
.exhaustive();
|
||||
|
||||
if (!authLevel) {
|
||||
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
|
||||
.with('ACCOUNT', () => 'Account Authentication')
|
||||
.with(null, () => 'Email')
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
return authLevel;
|
||||
};
|
||||
|
||||
const getRecipientAuditLogs = (recipientId: number) => {
|
||||
return {
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT].filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED
|
||||
].filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED &&
|
||||
log.data.recipientId === recipientId,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs[
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED
|
||||
].filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
|
||||
log.data.recipientId === recipientId,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const getRecipientSignatureField = (recipientId: number) => {
|
||||
return document.Recipient.find((recipient) => recipient.id === recipientId)?.Field.find(
|
||||
(field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">Signing Certificate</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Signer Events</TableHead>
|
||||
<TableHead>Signature</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
{/* <TableHead>Security</TableHead> */}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody className="print:text-xs">
|
||||
{document.Recipient.map((recipient, i) => {
|
||||
const logs = getRecipientAuditLogs(recipient.id);
|
||||
const signature = getRecipientSignatureField(recipient.id);
|
||||
|
||||
return (
|
||||
<TableRow key={i} className="print:break-inside-avoid">
|
||||
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
|
||||
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
|
||||
<div className="break-all">{recipient.email}</div>
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">Authentication Level:</span>{' '}
|
||||
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
|
||||
</p>
|
||||
</TableCell>
|
||||
|
||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||
{signature ? (
|
||||
<>
|
||||
<div
|
||||
className="inline-block rounded-lg p-1"
|
||||
style={{
|
||||
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${signature.Signature?.signatureImageAsBase64}`}
|
||||
alt="Signature"
|
||||
className="max-h-12 max-w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">Signature ID:</span>{' '}
|
||||
<span className="block font-mono uppercase">
|
||||
{signature.secondaryId}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">IP Address:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
|
||||
<span className="font-medium">Device:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">N/A</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Sent:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.EMAIL_SENT[0] ? (
|
||||
<LocaleDate
|
||||
date={logs.EMAIL_SENT[0].createdAt}
|
||||
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||
/>
|
||||
) : (
|
||||
'Unknown'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Viewed:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_OPENED[0] ? (
|
||||
<LocaleDate
|
||||
date={logs.DOCUMENT_OPENED[0].createdAt}
|
||||
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||
/>
|
||||
) : (
|
||||
'Unknown'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Signed:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? (
|
||||
<LocaleDate
|
||||
date={logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt}
|
||||
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||
/>
|
||||
) : (
|
||||
'Unknown'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Reason:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{isOwner(recipient.email)
|
||||
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||
: FRIENDLY_SIGNING_REASONS[recipient.role]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="my-8 flex-row-reverse">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||
Signing certificate provided by:
|
||||
</p>
|
||||
|
||||
<Logo className="max-h-6 print:max-h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { RedirectType, redirect } from 'next/navigation';
|
||||
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getOrganisationByUrl } from '@documenso/lib/server-only/organisation/get-organisation';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
import { OrganisationProvider } from '~/providers/organisation';
|
||||
|
||||
export type AuthenticatedOrganisationLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
orgUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AuthenticatedOrganisationLayout({
|
||||
children,
|
||||
params,
|
||||
}: AuthenticatedOrganisationLayoutProps) {
|
||||
const { session, user } = await getServerComponentSession();
|
||||
|
||||
if (!session || !user) {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const [getTeamsPromise, getOrganisationPromise] = await Promise.allSettled([
|
||||
getTeams({ userId: user.id }), // Todo: Orgs
|
||||
getOrganisationByUrl({ userId: user.id, organisationUrl: params.orgUrl }),
|
||||
]);
|
||||
|
||||
if (getOrganisationPromise.status === 'rejected') {
|
||||
redirect('/documents', RedirectType.replace);
|
||||
}
|
||||
|
||||
const organisation = getOrganisationPromise.value;
|
||||
const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : [];
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
{/* Todo: Orgs don't need limits... right? */}
|
||||
<LimitsProvider>
|
||||
{/* {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
|
||||
<LayoutBillingBanner
|
||||
subscription={team.subscription}
|
||||
teamId={team.id}
|
||||
userRole={team.currentTeamMember.role}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
{/* Todo: Orgs - Should we scope teams to orgs? */}
|
||||
<Header user={user} teams={teams} />
|
||||
|
||||
<OrganisationProvider organisation={organisation}>
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
</OrganisationProvider>
|
||||
|
||||
<RefreshOnFocus />
|
||||
</LimitsProvider>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getOrganisationByUrl } from '@documenso/lib/server-only/organisation/get-organisation';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
|
||||
import { DesktopNav } from '~/components/(organisations)/settings/layout/desktop-nav';
|
||||
import { MobileNav } from '~/components/(organisations)/settings/layout/mobile-nav';
|
||||
|
||||
export type OrganisationSettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
orgUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function OrganisationSettingsLayout({
|
||||
children,
|
||||
params: { orgUrl },
|
||||
}: OrganisationSettingsLayoutProps) {
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
try {
|
||||
const organisation = await getOrganisationByUrl({
|
||||
userId: session.user.id,
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentMember.role)) {
|
||||
throw new Error(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
} catch (e) {
|
||||
const error = AppError.parseError(e);
|
||||
|
||||
if (error.code === 'P2025') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">Organisation Settings</h1>
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
<DesktopNav className="hidden md:col-span-3 md:flex" />
|
||||
<MobileNav className="col-span-12 mb-8 md:hidden" />
|
||||
|
||||
<div className="col-span-12 md:col-span-9">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getOrganisationByUrl } from '@documenso/lib/server-only/organisation/get-organisation';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { InviteOrganisationMembersDialog } from '~/components/(organisations)/dialogs/invite-organisation-member-dialog';
|
||||
import { OrganisationMemberPageDataTable } from '~/components/(organisations)/tables/organisation-member-page-data-table';
|
||||
|
||||
export type OrganisationSettingsMembersPageProps = {
|
||||
params: {
|
||||
orgUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function OrganisationSettingsMembersPage({
|
||||
params,
|
||||
}: OrganisationSettingsMembersPageProps) {
|
||||
const { orgUrl } = params;
|
||||
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const organisation = await getOrganisationByUrl({
|
||||
userId: session.user.id,
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Members" subtitle="Manage organisation members or invite new members.">
|
||||
<InviteOrganisationMembersDialog
|
||||
organisationId={organisation.id}
|
||||
currentUserRole={organisation.currentMember.role}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
|
||||
<OrganisationMemberPageDataTable
|
||||
currentUserRole={organisation.currentMember.role}
|
||||
organisationId={organisation.id}
|
||||
organisationName={organisation.name}
|
||||
organisationOwnerUserId={organisation.ownerUserId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getOrganisationByUrl } from '@documenso/lib/server-only/organisation/get-organisation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { UpdateOrganisationForm } from '~/components/(organisations)/forms/update-organisation-form';
|
||||
|
||||
export type OrganisationSettingsPageProps = {
|
||||
params: {
|
||||
orgUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function OrganisationSettingsPage({ params }: OrganisationSettingsPageProps) {
|
||||
const { orgUrl } = params;
|
||||
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const organisation = await getOrganisationByUrl({
|
||||
userId: session.user.id,
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Organisation profile"
|
||||
subtitle="Here you can edit your organisation details."
|
||||
/>
|
||||
|
||||
<UpdateOrganisationForm
|
||||
organisationId={organisation.id}
|
||||
organisationName={organisation.name}
|
||||
organisationUrl={organisation.url}
|
||||
/>
|
||||
|
||||
<section className="mt-6 space-y-6">
|
||||
<Alert
|
||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Transfer or delete organisation</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
Please contact us at{' '}
|
||||
<a target="_blank" className="font-bold" href="mailto:support@documenso.com">
|
||||
support@documenso.com
|
||||
</a>{' '}
|
||||
to transfer or delete your organisation
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type ClaimAccountProps = {
|
||||
defaultName: string;
|
||||
defaultEmail: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ZClaimAccountFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||
email: z.string().email().min(1),
|
||||
password: ZPasswordSchema,
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const { name, email, password } = data;
|
||||
return !password.includes(name) && !password.includes(email.split('@')[0]);
|
||||
},
|
||||
{
|
||||
message: 'Password should not be common or based on personal information',
|
||||
path: ['password'],
|
||||
},
|
||||
);
|
||||
|
||||
export type TClaimAccountFormSchema = z.infer<typeof ZClaimAccountFormSchema>;
|
||||
|
||||
export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) => {
|
||||
const analytics = useAnalytics();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
||||
|
||||
const form = useForm<TClaimAccountFormSchema>({
|
||||
values: {
|
||||
name: defaultName ?? '',
|
||||
email: defaultEmail,
|
||||
password: '',
|
||||
},
|
||||
resolver: zodResolver(ZClaimAccountFormSchema),
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
|
||||
try {
|
||||
await signup({ name, email, password });
|
||||
|
||||
router.push(`/unverified-account`);
|
||||
|
||||
toast({
|
||||
title: 'Registration Successful',
|
||||
description:
|
||||
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
analytics.capture('App: User Claim Account', {
|
||||
email,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
|
||||
toast({
|
||||
title: 'An error occurred',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to sign you up. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 w-full">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="mt-4">
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Enter your name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>Email address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Enter your email" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>Set a password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="Pick a password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
|
||||
Claim account
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { CheckCircle2, Clock8 } from 'lucide-react';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { env } from 'next-runtime-env';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||
@@ -16,10 +17,13 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { SigningAuthPageView } from '../signing-auth-page';
|
||||
import { ClaimAccount } from './claim-account';
|
||||
import { DocumentPreviewButton } from './document-preview-button';
|
||||
|
||||
export type CompletedSigningPageProps = {
|
||||
@@ -31,6 +35,8 @@ export type CompletedSigningPageProps = {
|
||||
export default async function CompletedSigningPage({
|
||||
params: { token },
|
||||
}: CompletedSigningPageProps) {
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
if (!token) {
|
||||
return notFound();
|
||||
}
|
||||
@@ -79,96 +85,120 @@ export default async function CompletedSigningPage({
|
||||
|
||||
const sessionData = await getServerSession();
|
||||
const isLoggedIn = !!sessionData?.user;
|
||||
const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
|
||||
|
||||
return (
|
||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
|
||||
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
|
||||
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
|
||||
canSignUp,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn('flex flex-col items-center', {
|
||||
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
|
||||
})}
|
||||
>
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncatedTitle}
|
||||
</Badge>
|
||||
|
||||
<div className="relative mt-6 flex w-full flex-col items-center">
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<div className="text-documenso-700 flex items-center text-center">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Everyone has signed</span>
|
||||
</div>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="flex items-center text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Waiting for others to sign</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Document no longer available to sign</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
You have
|
||||
{recipient.role === RecipientRole.SIGNER && ' signed '}
|
||||
{recipient.role === RecipientRole.VIEWER && ' viewed '}
|
||||
{recipient.role === RecipientRole.APPROVER && ' approved '}
|
||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
||||
</h2>
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
Document
|
||||
{recipient.role === RecipientRole.SIGNER && ' Signed '}
|
||||
{recipient.role === RecipientRole.VIEWER && ' Viewed '}
|
||||
{recipient.role === RecipientRole.APPROVER && ' Approved '}
|
||||
</h2>
|
||||
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
Everyone has signed! You will receive an Email copy of the signed document.
|
||||
</p>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
You will receive an Email copy of the signed document once everyone has signed.
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
This document has been cancelled by the owner and is no longer available for others to
|
||||
sign.
|
||||
</p>
|
||||
))}
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<div className="text-documenso-700 mt-4 flex items-center text-center">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Everyone has signed</span>
|
||||
</div>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="flex items-center mt-4 text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Waiting for others to sign</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Document no longer available to sign</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
Everyone has signed! You will receive an Email copy of the signed document.
|
||||
</p>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
You will receive an Email copy of the signed document once everyone has signed.
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
This document has been cancelled by the owner and is no longer available for others
|
||||
to sign.
|
||||
</p>
|
||||
))}
|
||||
|
||||
{document.status === DocumentStatus.COMPLETED ? (
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={documentData}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
) : (
|
||||
<DocumentPreviewButton
|
||||
className="text-[11px]"
|
||||
title="Signatures will appear once the document has been completed"
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||
|
||||
{document.status === DocumentStatus.COMPLETED ? (
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={documentData}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
) : (
|
||||
<DocumentPreviewButton
|
||||
className="text-[11px]"
|
||||
title="Signatures will appear once the document has been completed"
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoggedIn ? (
|
||||
{canSignUp && (
|
||||
<div className={`flex max-w-xl flex-col items-center justify-center p-4 md:p-12`}>
|
||||
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
|
||||
Need to sign documents?
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
|
||||
Create your account and start using state-of-the-art document signing.
|
||||
</p>
|
||||
|
||||
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn && (
|
||||
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
||||
Go Back Home
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||
Want to send slick signing links like this one?{' '}
|
||||
<Link
|
||||
href="https://documenso.com"
|
||||
className="text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
Check out Documenso.
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
]);
|
||||
|
||||
if (!document || !document.documentData || !recipient) {
|
||||
if (
|
||||
!document ||
|
||||
!document.documentData ||
|
||||
!recipient ||
|
||||
document.status === DocumentStatus.DRAFT
|
||||
) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { getOrganisationById } from '@documenso/lib/server-only/organisation/get-organisation';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { InviteStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
type AcceptInvitationPageProps = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AcceptOrganisationInvitationPage({
|
||||
params: { token },
|
||||
}: AcceptInvitationPageProps) {
|
||||
const session = await getServerComponentSession();
|
||||
|
||||
const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationMemberInvite) {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="text-4xl font-semibold">Invalid token</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
This token is invalid or has expired. Please contact your organisation for a new
|
||||
invitation.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const organisation = await getOrganisationById({
|
||||
organisationId: organisationMemberInvite.organisationId,
|
||||
});
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: organisationMemberInvite.email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Directly convert the organisation member invite to a organisation member if they already have an account.
|
||||
if (user) {
|
||||
await acceptOrganisationInvitation({ userId: user.id, organisationId: organisation.id });
|
||||
}
|
||||
|
||||
// For users who do not exist yet, set the organisation invite status to accepted, which is checked during
|
||||
// user creation to determine if we should add the user to the organisation at that time.
|
||||
if (!user && organisationMemberInvite.status !== InviteStatus.ACCEPTED) {
|
||||
await prisma.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: InviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const email = encryptSecondaryData({
|
||||
data: organisationMemberInvite.email,
|
||||
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Organisation invitation</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
You have been invited by <strong>{organisation.name}</strong> to join their organisation.
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-1 text-sm">
|
||||
To accept this invitation you must create an account.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href={`/signup?email=${encodeURIComponent(email)}`}>Create account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invitation accepted!</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
You have accepted an invitation from <strong>{organisation.name}</strong> to join their
|
||||
organisation.
|
||||
</p>
|
||||
|
||||
{isSessionUserTheInvitedUser ? (
|
||||
<Button asChild>
|
||||
<Link href="/">Continue</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link href={`/signin?email=${encodeURIComponent(email)}`}>Continue to login</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -15,18 +16,21 @@ import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export type AvatarWithRecipientProps = {
|
||||
recipient: Recipient;
|
||||
documentStatus: DocumentStatus;
|
||||
};
|
||||
|
||||
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const { toast } = useToast();
|
||||
|
||||
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
|
||||
|
||||
const onRecipientClick = () => {
|
||||
if (!recipient.token) {
|
||||
if (!signingToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
|
||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The signing link has been copied to your clipboard.',
|
||||
@@ -37,10 +41,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('my-1 flex items-center gap-2', {
|
||||
'cursor-pointer hover:underline': recipient.token,
|
||||
'cursor-pointer hover:underline': signingToken,
|
||||
})}
|
||||
role={recipient.token ? 'button' : undefined}
|
||||
title={recipient.token && 'Click to copy signing link for sending to recipient'}
|
||||
role={signingToken ? 'button' : undefined}
|
||||
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
|
||||
onClick={onRecipientClick}
|
||||
>
|
||||
<StackAvatar
|
||||
@@ -49,16 +53,15 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className="text-muted-foreground text-sm"
|
||||
title="Click to copy signing link for sending to recipient"
|
||||
>
|
||||
<p>{recipient.email} </p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-muted-foreground text-sm"
|
||||
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
|
||||
>
|
||||
<p>{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRef, useState } from 'react';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
|
||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||
@@ -13,12 +13,14 @@ import { StackAvatar } from './stack-avatar';
|
||||
import { StackAvatars } from './stack-avatars';
|
||||
|
||||
export type StackAvatarsWithTooltipProps = {
|
||||
documentStatus: DocumentStatus;
|
||||
recipients: Recipient[];
|
||||
position?: 'top' | 'bottom';
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const StackAvatarsWithTooltip = ({
|
||||
documentStatus,
|
||||
recipients,
|
||||
position,
|
||||
children,
|
||||
@@ -120,7 +122,11 @@ export const StackAvatarsWithTooltip = ({
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Waiting</h1>
|
||||
{waitingRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -129,7 +135,11 @@ export const StackAvatarsWithTooltip = ({
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Opened</h1>
|
||||
{openedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -138,7 +148,11 @@ export const StackAvatarsWithTooltip = ({
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Uncompleted</h1>
|
||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
CommandDialog,
|
||||
@@ -71,7 +69,6 @@ export type CommandMenuProps = {
|
||||
|
||||
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
const { setTheme } = useTheme();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -93,17 +90,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const isOwner = useCallback(
|
||||
(document: Document) => document.userId === session?.user.id,
|
||||
[session?.user.id],
|
||||
);
|
||||
|
||||
const getSigningLink = useCallback(
|
||||
(recipients: Recipient[]) =>
|
||||
`/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`,
|
||||
[session?.user.email],
|
||||
);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchDocumentsData) {
|
||||
return [];
|
||||
@@ -111,10 +97,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
|
||||
return searchDocumentsData.map((document) => ({
|
||||
label: document.title,
|
||||
path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient),
|
||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||
path: document.path,
|
||||
value: document.value,
|
||||
}));
|
||||
}, [searchDocumentsData, isOwner, getSigningLink]);
|
||||
}, [searchDocumentsData]);
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -12,8 +10,6 @@ import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { CommandMenu } from '../common/command-menu';
|
||||
|
||||
const navigationLinks = [
|
||||
{
|
||||
href: '/documents',
|
||||
@@ -25,13 +21,14 @@ const navigationLinks = [
|
||||
},
|
||||
];
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement> & {
|
||||
setIsCommandMenuOpen: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => {
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||
|
||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||
@@ -70,12 +67,10 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CommandMenu open={open} onOpenChange={setOpen} />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
|
||||
onClick={() => setOpen((open) => !open)}
|
||||
onClick={() => setIsCommandMenuOpen(true)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
|
||||
@@ -58,7 +58,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
<Logo className="h-6 w-auto" />
|
||||
</Link>
|
||||
|
||||
<DesktopNav />
|
||||
<DesktopNav setIsCommandMenuOpen={setIsCommandMenuOpen} />
|
||||
|
||||
<div className="flex gap-x-4 md:ml-8">
|
||||
<MenuSwitcher user={user} teams={teams} />
|
||||
|
||||
@@ -93,7 +93,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
<Button
|
||||
data-testid="menu-switcher"
|
||||
variant="none"
|
||||
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent"
|
||||
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
||||
@@ -102,12 +102,13 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
rightSideComponent={
|
||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||
}
|
||||
textSectionClassName="hidden lg:flex"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className={cn('z-[60] ml-2 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
|
||||
@@ -46,7 +46,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<SheetContent className="flex w-full max-w-[400px] flex-col">
|
||||
<SheetContent className="flex w-full max-w-[350px] flex-col">
|
||||
<Link href="/" onClick={handleMenuItemClick}>
|
||||
<Image
|
||||
src={LogoImage}
|
||||
@@ -87,7 +87,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Braces, Building, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -35,19 +35,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/organisations">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Building className="mr-2 h-5 w-5" />
|
||||
Organisations
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/teams">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Braces, Building, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -38,19 +38,6 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/organisations">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Building className="mr-2 h-5 w-5" />
|
||||
Organisations
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/teams">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateOrganisationMutationSchema } from '@documenso/trpc/server/organisation-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type CreateOrganisationDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZCreateOrganisationFormSchema = ZCreateOrganisationMutationSchema.pick({
|
||||
organisationName: true,
|
||||
organisationUrl: true,
|
||||
});
|
||||
|
||||
type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFormSchema>;
|
||||
|
||||
export const CreateOrganisationDialog = ({ trigger, ...props }: CreateOrganisationDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const actionSearchParam = searchParams?.get('action');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
||||
defaultValues: {
|
||||
organisationName: '',
|
||||
organisationUrl: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createOrganisation } = trpc.organisation.createOrganisation.useMutation();
|
||||
|
||||
const onFormSubmit = async ({
|
||||
organisationName,
|
||||
organisationUrl,
|
||||
}: TCreateOrganisationFormSchema) => {
|
||||
try {
|
||||
await createOrganisation({
|
||||
organisationName,
|
||||
organisationUrl,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Your organisation has been created.',
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||
form.setError('organisationUrl', {
|
||||
type: 'manual',
|
||||
message: 'This URL is already in use.',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to create an organisation. Please try again later.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const mapTextToUrl = (text: string) => {
|
||||
return text.toLowerCase().replace(/\s+/g, '-');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (actionSearchParam === 'create-organisation') {
|
||||
setOpen(true);
|
||||
updateSearchParams({ action: null });
|
||||
}
|
||||
}, [actionSearchParam, open, setOpen, updateSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
Create organisation
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create organisation</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
Create an organisation to collaborate with teams.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organisationName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Organisation Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="bg-background"
|
||||
{...field}
|
||||
onChange={(event) => {
|
||||
const oldGeneratedUrl = mapTextToUrl(field.value);
|
||||
const newGeneratedUrl = mapTextToUrl(event.target.value);
|
||||
|
||||
const urlField = form.getValues('organisationUrl');
|
||||
if (urlField === oldGeneratedUrl) {
|
||||
form.setValue('organisationUrl', newGeneratedUrl);
|
||||
}
|
||||
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organisationUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Organisation URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
{!form.formState.errors.organisationUrl && (
|
||||
<span className="text-foreground/50 text-xs font-normal">
|
||||
{field.value
|
||||
? `${WEBAPP_BASE_URL}/orgs/${field.value}`
|
||||
: 'A unique URL to identify your organisation'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="dialog-create-organisation-button"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
Create organisation
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DeleteOrganisationMemberDialogProps = {
|
||||
organisationId: string;
|
||||
organisationName: string;
|
||||
organisationMemberId: string;
|
||||
organisationMemberName: string;
|
||||
organisationMemberEmail: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DeleteOrganisationMemberDialog = ({
|
||||
trigger,
|
||||
organisationId,
|
||||
organisationName,
|
||||
organisationMemberId,
|
||||
organisationMemberName,
|
||||
organisationMemberEmail,
|
||||
}: DeleteOrganisationMemberDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// Todo: Orgs - Add logic so we can't remove members who are owning teams.
|
||||
|
||||
const { mutateAsync: deleteOrganisationMembers, isLoading: isDeletingOrganisationMember } =
|
||||
trpc.organisation.deleteOrganisationMembers.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'You have successfully removed this user from the organisation.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
description:
|
||||
'We encountered an unknown error while attempting to remove this user. Please try again later.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeletingOrganisationMember && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? <Button variant="secondary">Delete organisation member</Button>}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
You are about to remove the following user from{' '}
|
||||
<span className="font-semibold">{organisationName}</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral" padding="tight">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={organisationMemberName.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className="font-semibold">{organisationMemberName}</span>}
|
||||
secondaryText={organisationMemberEmail}
|
||||
/>
|
||||
</Alert>
|
||||
|
||||
<fieldset disabled={isDeletingOrganisationMember}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDeletingOrganisationMember}
|
||||
onClick={async () =>
|
||||
deleteOrganisationMembers({
|
||||
organisationId,
|
||||
memberIds: [organisationMemberId],
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,248 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Mail, PlusCircle, Trash } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||
ORGANISATION_MEMBER_ROLE_MAP,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { OrganisationMemberRole } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateOrganisationMemberInvitesMutationSchema } from '@documenso/trpc/server/organisation-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type InviteOrganisationMembersDialogProps = {
|
||||
currentUserRole: OrganisationMemberRole;
|
||||
organisationId: string;
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZInviteOrganisationMembersFormSchema = z
|
||||
.object({
|
||||
invitations: ZCreateOrganisationMemberInvitesMutationSchema.shape.invitations,
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Members must have unique emails', path: ['members__root'] },
|
||||
);
|
||||
|
||||
type TInviteOrganisationMembersFormSchema = z.infer<typeof ZInviteOrganisationMembersFormSchema>;
|
||||
|
||||
export const InviteOrganisationMembersDialog = ({
|
||||
currentUserRole,
|
||||
organisationId,
|
||||
trigger,
|
||||
...props
|
||||
}: InviteOrganisationMembersDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<TInviteOrganisationMembersFormSchema>({
|
||||
resolver: zodResolver(ZInviteOrganisationMembersFormSchema),
|
||||
defaultValues: {
|
||||
invitations: [
|
||||
{
|
||||
email: '',
|
||||
role: OrganisationMemberRole.MEMBER,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
append: appendOrganisationMemberInvite,
|
||||
fields: organisationMemberInvites,
|
||||
remove: removeOrganisationMemberInvite,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'invitations',
|
||||
});
|
||||
|
||||
const { mutateAsync: createOrganisationMemberInvites } =
|
||||
trpc.organisation.createOrganisationMemberInvites.useMutation();
|
||||
|
||||
const onAddOrganisationMemberInvite = () => {
|
||||
appendOrganisationMemberInvite({
|
||||
email: '',
|
||||
role: OrganisationMemberRole.MEMBER,
|
||||
});
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ invitations }: TInviteOrganisationMembersFormSchema) => {
|
||||
try {
|
||||
await createOrganisationMemberInvites({
|
||||
organisationId,
|
||||
invitations,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Organisation invitations have been sent.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to invite organisation members. Please try again later.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? <Button variant="secondary">Invite member</Button>}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite organisation members</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
An email containing an invitation will be sent to each member.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{organisationMemberInvites.map((organisationMemberInvite, index) => (
|
||||
<div className="flex w-full flex-row space-x-4" key={organisationMemberInvite.id}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && <FormLabel required>Email address</FormLabel>}
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && <FormLabel required>Role</FormLabel>}
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserRole].map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{ORGANISATION_MEMBER_ROLE_MAP[role] ?? role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
index === 0 ? 'mt-8' : 'mt-0',
|
||||
)}
|
||||
disabled={organisationMemberInvites.length === 1}
|
||||
onClick={() => removeOrganisationMemberInvite(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-fit"
|
||||
onClick={() => onAddOrganisationMemberInvite()}
|
||||
>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Add more
|
||||
</Button>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
|
||||
Invite
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { OrganisationMemberRole } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type LeaveOrganisationDialogProps = {
|
||||
organisationId: string;
|
||||
organisationName: string;
|
||||
role: OrganisationMemberRole;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LeaveOrganisationDialog = ({
|
||||
trigger,
|
||||
organisationId,
|
||||
organisationName,
|
||||
role,
|
||||
}: LeaveOrganisationDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [errorCode, setErrorCode] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: leaveOrg, isLoading: isLeavingOrg } =
|
||||
trpc.organisation.leaveOrganisation.useMutation({
|
||||
onMutate: () => {
|
||||
setErrorCode(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'You have successfully left this organisation.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
setErrorCode(error.code);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLeavingOrg && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? <Button variant="destructive">Leave organisation</Button>}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
You are about to leave the following organisation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral" padding="tight">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={organisationName.slice(0, 1).toUpperCase()}
|
||||
primaryText={organisationName}
|
||||
secondaryText={ORGANISATION_MEMBER_ROLE_MAP[role]}
|
||||
/>
|
||||
</Alert>
|
||||
|
||||
{errorCode && (
|
||||
<Alert variant="destructive">
|
||||
{errorCode === 'USER_HAS_TEAMS'
|
||||
? 'You cannot leave an organisation if you are the owner of a team in it.'
|
||||
: 'We encountered an unknown error while attempting to leave this organisation. Please try again later.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<fieldset disabled={isLeavingOrg}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isLeavingOrg}
|
||||
onClick={async () => leaveOrg({ organisationId })}
|
||||
>
|
||||
Leave
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,189 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||
ORGANISATION_MEMBER_ROLE_MAP,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||
import { OrganisationMemberRole } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type UpdateOrganisationMemberDialogProps = {
|
||||
currentUserRole: OrganisationMemberRole;
|
||||
trigger?: React.ReactNode;
|
||||
organisationId: string;
|
||||
organisationMemberId: string;
|
||||
organisationMemberName: string;
|
||||
organisationMemberRole: OrganisationMemberRole;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZUpdateOrganisationMemberFormSchema = z.object({
|
||||
role: z.nativeEnum(OrganisationMemberRole),
|
||||
});
|
||||
|
||||
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
|
||||
|
||||
export const UpdateOrganisationMemberDialog = ({
|
||||
currentUserRole,
|
||||
trigger,
|
||||
organisationId,
|
||||
organisationMemberId,
|
||||
organisationMemberName,
|
||||
organisationMemberRole,
|
||||
...props
|
||||
}: UpdateOrganisationMemberDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<ZUpdateOrganisationMemberSchema>({
|
||||
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
|
||||
defaultValues: {
|
||||
role: organisationMemberRole,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisationMember } =
|
||||
trpc.organisation.updateOrganisationMember.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
|
||||
try {
|
||||
await updateOrganisationMember({
|
||||
organisationId,
|
||||
organisationMemberId,
|
||||
data: {
|
||||
role,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `You have updated ${organisationMemberName}.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to update this organisation member. Please try again later.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset();
|
||||
|
||||
if (!isOrganisationRoleWithinUserHierarchy(currentUserRole, organisationMemberRole)) {
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
title: 'You cannot modify a organisation member who has a higher role than you.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [open, currentUserRole, organisationMemberRole, form, toast]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? <Button variant="secondary">Update organisation member</Button>}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update organisation member</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
You are currently updating <span className="font-bold">{organisationMemberName}.</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel required>Role</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent className="w-full" position="popper">
|
||||
{ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserRole].map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{ORGANISATION_MEMBER_ROLE_MAP[role] ?? role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,177 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateOrganisationMutationSchema } from '@documenso/trpc/server/organisation-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type UpdateOrganisationDialogProps = {
|
||||
organisationId: string;
|
||||
organisationName: string;
|
||||
organisationUrl: string;
|
||||
};
|
||||
|
||||
const ZUpdateOrganisationFormSchema = ZUpdateOrganisationMutationSchema.shape.data.pick({
|
||||
name: true,
|
||||
url: true,
|
||||
});
|
||||
|
||||
type TUpdateOrganisationFormSchema = z.infer<typeof ZUpdateOrganisationFormSchema>;
|
||||
|
||||
export const UpdateOrganisationForm = ({
|
||||
organisationId,
|
||||
organisationName,
|
||||
organisationUrl,
|
||||
}: UpdateOrganisationDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZUpdateOrganisationFormSchema),
|
||||
defaultValues: {
|
||||
name: organisationName,
|
||||
url: organisationUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisation } = trpc.organisation.updateOrganisation.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ name, url }: TUpdateOrganisationFormSchema) => {
|
||||
try {
|
||||
await updateOrganisation({
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
},
|
||||
organisationId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Your organisation has been successfully updated.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
form.reset({
|
||||
name,
|
||||
url,
|
||||
});
|
||||
|
||||
if (url !== organisationUrl) {
|
||||
router.push(`${WEBAPP_BASE_URL}/orgs/${url}/settings`);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||
form.setError('url', {
|
||||
type: 'manual',
|
||||
message: 'This URL is already in use.',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to update your organisation. Please try again later.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Organisation Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel required>Organisation URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
{!form.formState.errors.url && (
|
||||
<span className="text-foreground/50 text-xs font-normal">
|
||||
{field.value
|
||||
? `${WEBAPP_BASE_URL}/orgs/${field.value}`
|
||||
: 'A unique URL to identify your organisation'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<AnimatePresence>
|
||||
{form.formState.isDirty && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<Button type="button" variant="secondary" onClick={() => form.reset()}>
|
||||
Reset
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="transition-opacity"
|
||||
disabled={!form.formState.isDirty}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
Update organisation
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { Braces, CreditCard, Settings, Users, Webhook } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
|
||||
const orgUrl = typeof params?.orgUrl === 'string' ? params?.orgUrl : '';
|
||||
|
||||
const settingsPath = `/orgs/${orgUrl}/settings`;
|
||||
const membersPath = `/orgs/${orgUrl}/settings/members`;
|
||||
const tokensPath = `/orgs/${orgUrl}/settings/tokens`;
|
||||
const webhooksPath = `/orgs/${orgUrl}/settings/webhooks`;
|
||||
const billingPath = `/orgs/${orgUrl}/settings/billing`;
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
<Link href={settingsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname === settingsPath && 'bg-secondary')}
|
||||
>
|
||||
<Settings className="mr-2 h-5 w-5" />
|
||||
General
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={membersPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
Members
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={tokensPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
|
||||
>
|
||||
<Braces className="mr-2 h-5 w-5" />
|
||||
API Tokens
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={webhooksPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(webhooksPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
Webhooks
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={billingPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(billingPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCard className="mr-2 h-5 w-5" />
|
||||
Billing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { Braces, CreditCard, Key, User, Webhook } from 'lucide-react';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
|
||||
const orgUrl = typeof params?.orgUrl === 'string' ? params?.orgUrl : '';
|
||||
|
||||
const settingsPath = `/orgs/${orgUrl}/settings`;
|
||||
const membersPath = `/orgs/${orgUrl}/settings/members`;
|
||||
const tokensPath = `/orgs/${orgUrl}/settings/tokens`;
|
||||
const webhooksPath = `/orgs/${orgUrl}/settings/webhooks`;
|
||||
const billingPath = `/orgs/${orgUrl}/settings/billing`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<Link href={settingsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(settingsPath) &&
|
||||
pathname.split('/').length === 4 &&
|
||||
'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<User className="mr-2 h-5 w-5" />
|
||||
General
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={membersPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Key className="mr-2 h-5 w-5" />
|
||||
Members
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={tokensPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
|
||||
>
|
||||
<Braces className="mr-2 h-5 w-5" />
|
||||
API Tokens
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={webhooksPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(webhooksPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
Webhooks
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link href={billingPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(billingPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCard className="mr-2 h-5 w-5" />
|
||||
Billing
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,161 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { LeaveOrganisationDialog } from '../dialogs/leave-organisation-dialog';
|
||||
|
||||
export const CurrentUserOrganisationsDataTable = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||
Object.fromEntries(searchParams ?? []),
|
||||
);
|
||||
|
||||
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||
trpc.organisation.findOrganisations.useQuery(
|
||||
{
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'Organisation',
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) => (
|
||||
<Link href={`/orgs/${row.original.url}`} scroll={false}>
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
||||
}
|
||||
secondaryText={`${WEBAPP_BASE_URL}/orgs/${row.original.url}`}
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Role',
|
||||
accessorKey: 'role',
|
||||
cell: ({ row }) =>
|
||||
row.original.ownerUserId === row.original.currentMember.userId
|
||||
? 'Owner'
|
||||
: ORGANISATION_MEMBER_ROLE_MAP[row.original.currentMember.role],
|
||||
},
|
||||
{
|
||||
header: 'Member Since',
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end space-x-2">
|
||||
{canExecuteOrganisationAction(
|
||||
'MANAGE_ORGANISATION',
|
||||
row.original.currentMember.role,
|
||||
) && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/orgs/${row.original.url}/settings`}>Manage</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<LeaveOrganisationDialog
|
||||
organisationId={row.original.id}
|
||||
organisationName={row.original.name}
|
||||
role={row.original.currentMember.role}
|
||||
trigger={
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={row.original.ownerUserId === row.original.currentMember.userId}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Leave
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading && isInitialLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell className="w-1/3 py-4 pr-4">
|
||||
<div className="flex w-full flex-row items-center">
|
||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||
|
||||
<div className="ml-2 flex flex-grow flex-col">
|
||||
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
|
||||
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-end space-x-2">
|
||||
<Skeleton className="h-10 w-20 rounded" />
|
||||
<Skeleton className="h-10 w-16 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
@@ -1,205 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { History, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
export type OrganisationMemberInvitesDataTableProps = {
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
export const OrganisationMemberInvitesDataTable = ({
|
||||
organisationId,
|
||||
}: OrganisationMemberInvitesDataTableProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||
Object.fromEntries(searchParams ?? []),
|
||||
);
|
||||
|
||||
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||
trpc.organisation.findOrganisationMemberInvites.useQuery(
|
||||
{
|
||||
organisationId,
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: resendOrganisationMemberInvitation } =
|
||||
trpc.organisation.resendOrganisationMemberInvitation.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Invitation has been resent',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Unable to resend invitation. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteOrganisationMemberInvitations } =
|
||||
trpc.organisation.deleteOrganisationMemberInvitations.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Invitation has been deleted',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Unable to delete invitation. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'Organisation Member',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={row.original.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 font-semibold">{row.original.email}</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Role',
|
||||
accessorKey: 'role',
|
||||
cell: ({ row }) => ORGANISATION_MEMBER_ROLE_MAP[row.original.role] ?? row.original.role,
|
||||
},
|
||||
{
|
||||
header: 'Invited At',
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () =>
|
||||
resendOrganisationMemberInvitation({
|
||||
organisationId,
|
||||
invitationId: row.original.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Resend
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () =>
|
||||
deleteOrganisationMemberInvitations({
|
||||
organisationId,
|
||||
invitationIds: [row.original.id],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading && isInitialLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell className="w-1/2 py-4 pr-4">
|
||||
<div className="flex w-full flex-row items-center">
|
||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||
<Skeleton className="ml-2 h-4 w-1/3 max-w-[10rem]" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-6 rounded-full" />
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import type { OrganisationMemberRole } from '@documenso/prisma/client';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { OrganisationMemberInvitesDataTable } from '~/components/(organisations)/tables/organisation-member-invites-data-table';
|
||||
import { OrganisationMembersDataTable } from '~/components/(organisations)/tables/organisation-members-data-table';
|
||||
|
||||
export type OrganisationMemberPageDataTableProps = {
|
||||
currentUserRole: OrganisationMemberRole;
|
||||
organisationId: string;
|
||||
organisationName: string;
|
||||
organisationOwnerUserId: number;
|
||||
};
|
||||
|
||||
export const OrganisationMemberPageDataTable = ({
|
||||
currentUserRole,
|
||||
organisationId,
|
||||
organisationName,
|
||||
organisationOwnerUserId,
|
||||
}: OrganisationMemberPageDataTableProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}, [debouncedSearchQuery, pathname, router, searchParams]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="my-4 flex flex-row items-center justify-between space-x-4">
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
/>
|
||||
|
||||
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
||||
<TabsList>
|
||||
<TabsTrigger className="min-w-[60px]" value="members" asChild>
|
||||
<Link href={pathname ?? '/'}>Active</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
|
||||
<Link href={`${pathname}?tab=invites`}>Pending</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{currentTab === 'invites' ? (
|
||||
<OrganisationMemberInvitesDataTable key="invites" organisationId={organisationId} />
|
||||
) : (
|
||||
<OrganisationMembersDataTable
|
||||
key="members"
|
||||
currentUserRole={currentUserRole}
|
||||
organisationId={organisationId}
|
||||
organisationName={organisationName}
|
||||
organisationOwnerUserId={organisationOwnerUserId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,210 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { OrganisationMemberRole } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { DeleteOrganisationMemberDialog } from '../dialogs/delete-organisation-member-dialog';
|
||||
import { UpdateOrganisationMemberDialog } from '../dialogs/update-organisation-member-dialog';
|
||||
|
||||
export type OrganisationMembersDataTableProps = {
|
||||
currentUserRole: OrganisationMemberRole;
|
||||
organisationOwnerUserId: number;
|
||||
organisationId: string;
|
||||
organisationName: string;
|
||||
};
|
||||
|
||||
export const OrganisationMembersDataTable = ({
|
||||
currentUserRole,
|
||||
organisationOwnerUserId,
|
||||
organisationId,
|
||||
organisationName,
|
||||
}: OrganisationMembersDataTableProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||
Object.fromEntries(searchParams ?? []),
|
||||
);
|
||||
|
||||
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||
trpc.organisation.findOrganisationMembers.useQuery(
|
||||
{
|
||||
organisationId,
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'Organisation Member',
|
||||
cell: ({ row }) => {
|
||||
const avatarFallbackText = row.original.user.name
|
||||
? extractInitials(row.original.user.name)
|
||||
: row.original.user.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={avatarFallbackText}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 font-semibold">{row.original.user.name}</span>
|
||||
}
|
||||
secondaryText={row.original.user.email}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Role',
|
||||
accessorKey: 'role',
|
||||
cell: ({ row }) =>
|
||||
organisationOwnerUserId === row.original.userId
|
||||
? 'Owner'
|
||||
: ORGANISATION_MEMBER_ROLE_MAP[row.original.role],
|
||||
},
|
||||
{
|
||||
header: 'Member Since',
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
|
||||
<UpdateOrganisationMemberDialog
|
||||
currentUserRole={currentUserRole}
|
||||
organisationId={row.original.organisationId}
|
||||
organisationMemberId={row.original.id}
|
||||
organisationMemberName={row.original.user.name ?? ''}
|
||||
organisationMemberRole={row.original.role}
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
disabled={
|
||||
organisationOwnerUserId === row.original.userId ||
|
||||
!isOrganisationRoleWithinUserHierarchy(currentUserRole, row.original.role)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
title="Update organisation member role"
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Update role
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<DeleteOrganisationMemberDialog
|
||||
organisationId={organisationId}
|
||||
organisationName={organisationName}
|
||||
organisationMemberId={row.original.id}
|
||||
organisationMemberName={row.original.user.name ?? ''}
|
||||
organisationMemberEmail={row.original.user.email}
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
disabled={
|
||||
organisationOwnerUserId === row.original.userId ||
|
||||
!isOrganisationRoleWithinUserHierarchy(currentUserRole, row.original.role)
|
||||
}
|
||||
title="Remove organisation member"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading && isInitialLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell className="w-1/2 py-4 pr-4">
|
||||
<div className="flex w-full flex-row items-center">
|
||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||
|
||||
<div className="ml-2 flex flex-grow flex-col">
|
||||
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
|
||||
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-6 rounded-full" />
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Mail, PlusCircle, Trash } from 'lucide-react';
|
||||
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
|
||||
import Papa, { type ParseResult } from 'papaparse';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -39,6 +42,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type InviteTeamMembersDialogProps = {
|
||||
@@ -51,18 +55,45 @@ const ZInviteTeamMembersFormSchema = z
|
||||
.object({
|
||||
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase());
|
||||
// Display exactly which rows are duplicates.
|
||||
.superRefine((items, ctx) => {
|
||||
const uniqueEmails = new Map<string, number>();
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Members must have unique emails', path: ['members__root'] },
|
||||
);
|
||||
for (const [index, invitation] of items.invitations.entries()) {
|
||||
const email = invitation.email.toLowerCase();
|
||||
|
||||
const firstFoundIndex = uniqueEmails.get(email);
|
||||
|
||||
if (firstFoundIndex === undefined) {
|
||||
uniqueEmails.set(email, index);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['invitations', index, 'email'],
|
||||
});
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['invitations', firstFoundIndex, 'email'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
|
||||
|
||||
type TabTypes = 'INDIVIDUAL' | 'BULK';
|
||||
|
||||
const ZImportTeamMemberSchema = z.array(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
role: z.nativeEnum(TeamMemberRole),
|
||||
}),
|
||||
);
|
||||
|
||||
export const InviteTeamMembersDialog = ({
|
||||
currentUserTeamRole,
|
||||
teamId,
|
||||
@@ -70,6 +101,8 @@ export const InviteTeamMembersDialog = ({
|
||||
...props
|
||||
}: InviteTeamMembersDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -130,9 +163,75 @@ export const InviteTeamMembersDialog = ({
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setInvitationType('INDIVIDUAL');
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const csvFile = e.target.files[0];
|
||||
|
||||
Papa.parse(csvFile, {
|
||||
skipEmptyLines: true,
|
||||
comments: 'Work email,Job title',
|
||||
complete: (results: ParseResult<string[]>) => {
|
||||
const members = results.data.map((row) => {
|
||||
const [email, role] = row;
|
||||
|
||||
return {
|
||||
email: email.trim(),
|
||||
role: role.trim().toUpperCase(),
|
||||
};
|
||||
});
|
||||
|
||||
// Remove the first row if it contains the headers.
|
||||
if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') {
|
||||
members.shift();
|
||||
}
|
||||
|
||||
try {
|
||||
const importedInvitations = ZImportTeamMemberSchema.parse(members);
|
||||
|
||||
form.setValue('invitations', importedInvitations);
|
||||
form.clearErrors('invitations');
|
||||
|
||||
setInvitationType('INDIVIDUAL');
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Something went wrong',
|
||||
description: 'Please check the CSV file and make sure it is according to our format',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const data = [
|
||||
{ email: 'admin@documenso.com', role: 'Admin' },
|
||||
{ email: 'manager@documenso.com', role: 'Manager' },
|
||||
{ email: 'member@documenso.com', role: 'Member' },
|
||||
];
|
||||
|
||||
const csvContent =
|
||||
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], {
|
||||
type: 'text/csv',
|
||||
});
|
||||
|
||||
downloadFile({
|
||||
filename: 'documenso-team-member-invites-template.csv',
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
@@ -152,92 +251,144 @@ export const InviteTeamMembersDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{teamMemberInvites.map((teamMemberInvite, index) => (
|
||||
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && <FormLabel required>Email address</FormLabel>}
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Tabs
|
||||
defaultValue="INDIVIDUAL"
|
||||
value={invitationType}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
onValueChange={(value) => setInvitationType(value as TabTypes)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
|
||||
<MailIcon size={20} className="mr-2" />
|
||||
Invite Members
|
||||
</TabsTrigger>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && <FormLabel required>Role</FormLabel>}
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
|
||||
<UsersIcon size={20} className="mr-2" /> Bulk Import
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<TabsContent value="INDIVIDUAL">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||
{teamMemberInvites.map((teamMemberInvite, index) => (
|
||||
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && <FormLabel required>Email address</FormLabel>}
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && <FormLabel required>Role</FormLabel>}
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
index === 0 ? 'mt-8' : 'mt-0',
|
||||
)}
|
||||
disabled={teamMemberInvites.length === 1}
|
||||
onClick={() => removeTeamMemberInvite(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(
|
||||
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
index === 0 ? 'mt-8' : 'mt-0',
|
||||
)}
|
||||
disabled={teamMemberInvites.length === 1}
|
||||
onClick={() => removeTeamMemberInvite(index)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-fit"
|
||||
onClick={() => onAddTeamMemberInvite()}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Add more
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-fit"
|
||||
onClick={() => onAddTeamMemberInvite()}
|
||||
>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Add more
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
|
||||
Invite
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="BULK">
|
||||
<div className="mt-4 space-y-4">
|
||||
<Card gradient className="h-32">
|
||||
<CardContent
|
||||
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
|
||||
<p className="mt-1 text-sm">Click here to upload</p>
|
||||
|
||||
<input
|
||||
onChange={onFileInputChange}
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".csv"
|
||||
hidden
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
|
||||
Invite
|
||||
<Button type="button" variant="secondary" onClick={downloadTemplate}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Template
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { Organisation } from '@documenso/prisma/client';
|
||||
|
||||
interface OrganisationProviderProps {
|
||||
children: React.ReactNode;
|
||||
organisation: Organisation;
|
||||
}
|
||||
|
||||
const OrganisationContext = createContext<Organisation | null>(null);
|
||||
|
||||
export const useCurrentOrganisation = () => {
|
||||
const context = useContext(OrganisationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCurrentOrganisation must be used within a OrganisationProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useOptionalCurrentOrganisation = () => {
|
||||
return useContext(OrganisationContext);
|
||||
};
|
||||
|
||||
export const OrganisationProvider = ({ children, organisation }: OrganisationProviderProps) => {
|
||||
return (
|
||||
<OrganisationContext.Provider value={organisation}>{children}</OrganisationContext.Provider>
|
||||
);
|
||||
};
|
||||
102
package-lock.json
generated
102
package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "^1.43.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
@@ -42,6 +43,7 @@
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@openstatus/react": "^0.0.3",
|
||||
"contentlayer": "^0.3.4",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.279.0",
|
||||
@@ -115,6 +117,7 @@
|
||||
"next-axiom": "^1.1.1",
|
||||
"next-plausible": "^3.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"posthog-js": "^1.75.3",
|
||||
"posthog-node": "^3.1.1",
|
||||
@@ -138,6 +141,7 @@
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/node": "20.1.0",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
@@ -4140,6 +4144,14 @@
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="
|
||||
},
|
||||
"node_modules/@openstatus/react": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@openstatus/react/-/react-0.0.3.tgz",
|
||||
"integrity": "sha512-uDiegz7e3H67pG8lTT+op+6w5keTT7XpcENrREaqlWl5j53TYyO8nheOG1PeNw2/Qgd5KaGeRJJFn1crhTUSYw==",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz",
|
||||
@@ -4690,6 +4702,19 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/browser-chromium": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz",
|
||||
"integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.43.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
|
||||
@@ -4705,6 +4730,50 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
||||
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.40.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright-core": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
||||
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz",
|
||||
@@ -8081,6 +8150,15 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
|
||||
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="
|
||||
},
|
||||
"node_modules/@types/papaparse": {
|
||||
"version": "5.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz",
|
||||
"integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse5": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz",
|
||||
@@ -17254,6 +17332,11 @@
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||
},
|
||||
"node_modules/papaparse": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
|
||||
"integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -17590,12 +17673,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
||||
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
||||
"dev": true,
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
|
||||
"integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.40.0"
|
||||
"playwright-core": "1.43.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -17608,10 +17690,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
||||
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
||||
"dev": true,
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
|
||||
"integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
@@ -17623,7 +17704,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -24901,6 +24981,7 @@
|
||||
"next-auth": "4.24.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"playwright": "^1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"stripe": "^12.7.0",
|
||||
@@ -24908,6 +24989,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "^1.43.0",
|
||||
"@types/luxon": "^3.3.1"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "^1.43.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
@@ -59,4 +60,4 @@
|
||||
"next": "14.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ZDeleteDocumentMutationSchema,
|
||||
ZDeleteFieldMutationSchema,
|
||||
ZDeleteRecipientMutationSchema,
|
||||
ZDownloadDocumentSuccessfulSchema,
|
||||
ZGetDocumentsQuerySchema,
|
||||
ZSendDocumentForSigningMutationSchema,
|
||||
ZSuccessfulDocumentResponseSchema,
|
||||
@@ -51,6 +52,17 @@ export const ApiContractV1 = c.router(
|
||||
summary: 'Get a single document',
|
||||
},
|
||||
|
||||
downloadSignedDocument: {
|
||||
method: 'GET',
|
||||
path: '/api/v1/documents/:id/download',
|
||||
responses: {
|
||||
200: ZDownloadDocumentSuccessfulSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Download a signed document when the storage transport is S3',
|
||||
},
|
||||
|
||||
createDocument: {
|
||||
method: 'POST',
|
||||
path: '/api/v1/documents',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createField } from '@documenso/lib/server-only/field/create-field';
|
||||
import { deleteField } from '@documenso/lib/server-only/field/delete-field';
|
||||
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
|
||||
import { updateField } from '@documenso/lib/server-only/field/update-field';
|
||||
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient';
|
||||
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
@@ -20,7 +21,12 @@ import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/s
|
||||
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import {
|
||||
getPresignGetUrl,
|
||||
getPresignPostUrl,
|
||||
} from '@documenso/lib/universal/upload/server-actions';
|
||||
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { ApiContractV1 } from './contract';
|
||||
@@ -80,6 +86,68 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
}
|
||||
}),
|
||||
|
||||
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
|
||||
try {
|
||||
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
message: 'Please make sure the storage transport is set to S3.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: Number(documentId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
if (!document || !document.documentDataId) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (DocumentDataType.S3_PATH !== document.documentData.type) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Invalid document data type',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (document.status !== DocumentStatus.COMPLETED) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Document is not completed yet.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { url } = await getPresignGetUrl(document.documentData.data);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { downloadUrl: url },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
message: 'Error downloading the document. Please try again.',
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
deleteDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
|
||||
@@ -156,6 +224,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
title: body.title,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
formValues: body.formValues,
|
||||
documentDataId: documentData.id,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
@@ -217,12 +286,37 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
recipients: body.recipients,
|
||||
});
|
||||
|
||||
let documentDataId = document.documentDataId;
|
||||
|
||||
if (body.formValues) {
|
||||
const pdf = await getFile(document.documentData);
|
||||
|
||||
const prefilled = await insertFormValuesInPdf({
|
||||
pdf: Buffer.from(pdf),
|
||||
formValues: body.formValues,
|
||||
});
|
||||
|
||||
const newDocumentData = await putFile({
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
});
|
||||
|
||||
documentDataId = newDocumentData.id;
|
||||
}
|
||||
|
||||
await updateDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
data: {
|
||||
title: fileName,
|
||||
formValues: body.formValues,
|
||||
documentData: {
|
||||
connect: {
|
||||
id: documentDataId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,16 +2,34 @@ import { generateOpenApi } from '@ts-rest/open-api';
|
||||
|
||||
import { ApiContractV1 } from './contract';
|
||||
|
||||
export const OpenAPIV1 = generateOpenApi(
|
||||
ApiContractV1,
|
||||
{
|
||||
info: {
|
||||
title: 'Documenso API',
|
||||
version: '1.0.0',
|
||||
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||
export const OpenAPIV1 = Object.assign(
|
||||
generateOpenApi(
|
||||
ApiContractV1,
|
||||
{
|
||||
info: {
|
||||
title: 'Documenso API',
|
||||
version: '1.0.0',
|
||||
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
setOperationId: true,
|
||||
},
|
||||
),
|
||||
{
|
||||
setOperationId: true,
|
||||
components: {
|
||||
securitySchemes: {
|
||||
authorization: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'Authorization',
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [
|
||||
{
|
||||
authorization: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -53,6 +53,10 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentSuccessfulSchema = z.object({
|
||||
downloadUrl: z.string(),
|
||||
});
|
||||
|
||||
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
|
||||
|
||||
export const ZCreateDocumentMutationSchema = z.object({
|
||||
@@ -73,6 +77,7 @@ export const ZCreateDocumentMutationSchema = z.object({
|
||||
redirectUrl: z.string(),
|
||||
})
|
||||
.partial(),
|
||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||
});
|
||||
|
||||
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
|
||||
@@ -112,6 +117,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||
});
|
||||
|
||||
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||
|
||||
@@ -45,7 +45,7 @@ test.describe('[EE_ONLY]', () => {
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Email', exact: true })
|
||||
.fill('recipient2@documenso.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||
|
||||
// Display advanced settings.
|
||||
await page.getByLabel('Show advanced settings').click();
|
||||
@@ -82,7 +82,7 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||
|
||||
// Advanced settings should not be visible for non EE users.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||
|
||||
@@ -136,7 +136,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByPlaceholder('Name').fill('User 1');
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
@@ -254,7 +254,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${token}/complete`);
|
||||
await expect(page.getByText('You have signed')).toBeVisible();
|
||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken(token);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
@@ -74,7 +75,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// open actions menu
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByRole('cell', { name: 'Download' })
|
||||
@@ -115,7 +116,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// open actions menu
|
||||
// Open document action menu.
|
||||
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
|
||||
|
||||
// delete document
|
||||
@@ -135,20 +136,11 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
|
||||
|
||||
await page.goto('/documents');
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => {
|
||||
const { sender } = await seedDeleteDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
@@ -156,11 +148,10 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// open actions menu
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Draft' })
|
||||
.getByRole('cell', { name: 'Edit' })
|
||||
.getByRole('button')
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
// delete document
|
||||
@@ -169,4 +160,155 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
|
||||
const { sender } = await seedDeleteDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
|
||||
// Sign into the recipient account.
|
||||
await apiSignout({ page });
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipients[0].email,
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible();
|
||||
await checkDocumentTabCount(page, 'Inbox', 1);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
|
||||
const recipientA = recipients[0];
|
||||
const recipientB = recipients[1];
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipientA.email,
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).click();
|
||||
await page.getByRole('button', { name: 'Hide' }).click();
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).click();
|
||||
await page.getByRole('button', { name: 'Hide' }).click();
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 0);
|
||||
|
||||
// Sign into the sender account.
|
||||
await apiSignout({ page });
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// Check document counts for sender.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
// Sign into the other recipient account.
|
||||
await apiSignout({ page });
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipientB.email,
|
||||
});
|
||||
|
||||
// Check document counts for other recipient.
|
||||
await checkDocumentTabCount(page, 'Inbox', 1);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 0);
|
||||
await checkDocumentTabCount(page, 'All', 2);
|
||||
});
|
||||
|
||||
17
packages/app-tests/e2e/fixtures/documents.ts
Normal file
17
packages/app-tests/e2e/fixtures/documents.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
await page.getByRole('tab', { name: tabName }).click();
|
||||
|
||||
if (tabName !== 'All') {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await expect(page.getByTestId('empty-document-state')).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
@@ -7,24 +6,10 @@ import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/se
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
await page.getByRole('tab', { name: tabName }).click();
|
||||
|
||||
if (tabName !== 'All') {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await expect(page.getByRole('main')).toContainText(`Nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
|
||||
};
|
||||
|
||||
test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
const { team, teamMember2 } = await seedTeamDocuments();
|
||||
|
||||
@@ -245,24 +230,6 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: currentUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
});
|
||||
|
||||
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||
|
||||
@@ -280,3 +247,125 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
|
||||
await expect(page.getByRole('status')).toContainText('Document re-sent');
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamMember3.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
|
||||
// Should be hidden for all team members.
|
||||
await apiSignout({ page });
|
||||
|
||||
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
||||
for (const user of [team.owner, teamEmailMember]) {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamMember3.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
|
||||
// Should be hidden for all team members.
|
||||
await apiSignout({ page });
|
||||
|
||||
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
||||
for (const user of [team.owner, teamEmailMember]) {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete completed team document', async ({ page }) => {
|
||||
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamMember3.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(2).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
|
||||
// Should be hidden for all team members.
|
||||
await apiSignout({ page });
|
||||
|
||||
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
||||
for (const user of [team.owner, teamEmailMember]) {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 4);
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ export const TemplateDocumentCancel = ({
|
||||
<br />"{documentName}"
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
All signatures have been voided.
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
You don't need to sign it anymore.
|
||||
</Text>
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface TemplateDocumentInviteProps {
|
||||
signDocumentLink: string;
|
||||
assetBaseUrl: string;
|
||||
role: RecipientRole;
|
||||
selfSigner: boolean;
|
||||
}
|
||||
|
||||
export const TemplateDocumentInvite = ({
|
||||
@@ -19,6 +20,7 @@ export const TemplateDocumentInvite = ({
|
||||
signDocumentLink,
|
||||
assetBaseUrl,
|
||||
role,
|
||||
selfSigner,
|
||||
}: TemplateDocumentInviteProps) => {
|
||||
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||
|
||||
@@ -28,8 +30,19 @@ export const TemplateDocumentInvite = ({
|
||||
|
||||
<Section>
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{inviterName} has invited you to {actionVerb.toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
{selfSigner ? (
|
||||
<>
|
||||
{`Please ${actionVerb.toLowerCase()} your document`}
|
||||
<br />
|
||||
{`"${documentName}"`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{`${inviterName} has invited you to ${actionVerb.toLowerCase()}`}
|
||||
<br />
|
||||
{`"${documentName}"`}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
|
||||
@@ -22,6 +22,7 @@ import { TemplateFooter } from '../template-components/template-footer';
|
||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||
customBody?: string;
|
||||
role: RecipientRole;
|
||||
selfSigner?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentInviteEmailTemplate = ({
|
||||
@@ -32,10 +33,13 @@ export const DocumentInviteEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
customBody,
|
||||
role,
|
||||
selfSigner = false,
|
||||
}: DocumentInviteEmailTemplateProps) => {
|
||||
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
|
||||
|
||||
const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
|
||||
const previewText = selfSigner
|
||||
? `Please ${action} your document ${documentName}`
|
||||
: `${inviterName} has invited you to ${action} ${documentName}`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
@@ -71,6 +75,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
signDocumentLink={signDocumentLink}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
role={role}
|
||||
selfSigner={selfSigner}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { formatOrganisationUrl } from '@documenso/lib/utils/organisations';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type OrganisationInviteEmailProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
senderName: string;
|
||||
organisationName: string;
|
||||
organisationUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const OrganisationInviteEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
senderName = 'John Doe',
|
||||
organisationName = 'Organisation Name',
|
||||
organisationUrl = 'demo',
|
||||
token = '',
|
||||
}: OrganisationInviteEmailProps) => {
|
||||
const previewText = `Accept invitation to join a organisation on Documenso`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="add-user.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
Join {organisationName} on Documenso
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
You have been invited to join the following organisation
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
{formatOrganisationUrl(organisationUrl, baseUrl)}
|
||||
</div>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
by <span className="text-slate-900">{senderName}</span>
|
||||
</Text>
|
||||
|
||||
{/* Todo: Orgs - Display warnings. */}
|
||||
|
||||
<Section className="mb-6 mt-6 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={`${baseUrl}/organisation/invite/${token}`}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganisationInviteEmailTemplate;
|
||||
@@ -1,30 +0,0 @@
|
||||
import { OrganisationMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
export const ORGANISATION_URL_ROOT_REGEX = new RegExp('^/orgs/[^/]+$');
|
||||
export const ORGANISATION_URL_REGEX = new RegExp('^/orgs/[^/]+');
|
||||
|
||||
export const ORGANISATION_MEMBER_ROLE_MAP: Record<keyof typeof OrganisationMemberRole, string> = {
|
||||
ADMIN: 'Admin',
|
||||
MANAGER: 'Manager',
|
||||
MEMBER: 'Member',
|
||||
};
|
||||
|
||||
// Todo: Orgs
|
||||
export const ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP = {
|
||||
MANAGE_ORGANISATION: [OrganisationMemberRole.ADMIN, OrganisationMemberRole.MANAGER],
|
||||
MANAGE_BILLING: [OrganisationMemberRole.ADMIN],
|
||||
DELETE_ORGANISATION_TRANSFER_REQUEST: [OrganisationMemberRole.ADMIN],
|
||||
} satisfies Record<string, OrganisationMemberRole[]>;
|
||||
|
||||
/**
|
||||
* A hierarchy of member roles to determine which role has higher permission than another.
|
||||
*/
|
||||
export const ORGANISATION_MEMBER_ROLE_HIERARCHY = {
|
||||
[OrganisationMemberRole.ADMIN]: [
|
||||
OrganisationMemberRole.ADMIN,
|
||||
OrganisationMemberRole.MANAGER,
|
||||
OrganisationMemberRole.MEMBER,
|
||||
],
|
||||
[OrganisationMemberRole.MANAGER]: [OrganisationMemberRole.MANAGER, OrganisationMemberRole.MEMBER],
|
||||
[OrganisationMemberRole.MEMBER]: [OrganisationMemberRole.MEMBER],
|
||||
} satisfies Record<OrganisationMemberRole, OrganisationMemberRole[]>;
|
||||
@@ -32,3 +32,10 @@ export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
||||
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
|
||||
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
|
||||
} as const;
|
||||
|
||||
export const RECIPIENT_ROLE_SIGNING_REASONS = {
|
||||
[RecipientRole.SIGNER]: 'I am a signer of this document',
|
||||
[RecipientRole.APPROVER]: 'I am an approver of this document',
|
||||
[RecipientRole.CC]: 'I am required to recieve a copy of this document',
|
||||
[RecipientRole.VIEWER]: 'I am a viewer of this document',
|
||||
} satisfies Record<keyof typeof RecipientRole, string>;
|
||||
|
||||
@@ -79,10 +79,6 @@ export const PROTECTED_TEAM_URLS = [
|
||||
'logout',
|
||||
'maintenance',
|
||||
'malware',
|
||||
'org',
|
||||
'orgs',
|
||||
'organisation',
|
||||
'organisations',
|
||||
'newsletter',
|
||||
'policy',
|
||||
'privacy',
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"next-auth": "4.24.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"playwright": "^1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"stripe": "^12.7.0",
|
||||
@@ -46,6 +47,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.3.1"
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@playwright/browser-chromium": "^1.43.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,14 @@ export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
User: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
Recipient: {
|
||||
include: {
|
||||
Field: {
|
||||
|
||||
@@ -49,8 +49,8 @@ export const completeDocumentWithToken = async ({
|
||||
|
||||
const document = await getDocument({ token, documentId });
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has already been completed`);
|
||||
if (document.status !== DocumentStatus.PENDING) {
|
||||
throw new Error(`Document ${document.id} must be pending`);
|
||||
}
|
||||
|
||||
if (document.Recipient.length === 0) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export type CreateDocumentOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentDataId: string;
|
||||
formValues?: Record<string, string | number | boolean>;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
@@ -22,6 +23,7 @@ export const createDocument = async ({
|
||||
title,
|
||||
documentDataId,
|
||||
teamId,
|
||||
formValues,
|
||||
requestMetadata,
|
||||
}: CreateDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@@ -51,6 +53,7 @@ export const createDocument = async ({
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
formValues,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user