Compare commits
4 Commits
v1.5.0-rc.
...
webhooks_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9b2abcadd | ||
|
|
99a26065a8 | ||
|
|
91375a17c2 | ||
|
|
a0aeca48f2 |
@@ -10,13 +10,7 @@
|
|||||||
"ghcr.io/devcontainers/features/node:1": {}
|
"ghcr.io/devcontainers/features/node:1": {}
|
||||||
},
|
},
|
||||||
"onCreateCommand": "./.devcontainer/on-create.sh",
|
"onCreateCommand": "./.devcontainer/on-create.sh",
|
||||||
"forwardPorts": [
|
"forwardPorts": [3000, 54320, 9000, 2500, 1100],
|
||||||
3000,
|
|
||||||
54320,
|
|
||||||
9000,
|
|
||||||
2500,
|
|
||||||
1100
|
|
||||||
],
|
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
@@ -31,7 +25,7 @@
|
|||||||
"GitHub.copilot",
|
"GitHub.copilot",
|
||||||
"GitHub.vscode-pull-request-github",
|
"GitHub.vscode-pull-request-github",
|
||||||
"Prisma.prisma",
|
"Prisma.prisma",
|
||||||
"VisualStudioExptTeam.vscodeintellicode"
|
"VisualStudioExptTeam.vscodeintellicode",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
> 🚨 It is Launch Week #2 - Day 1: We launches teams 🎉 https://documen.so/day1
|
>We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty
|
||||||
|
|
||||||
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ tags:
|
|||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
> 🔔 UPDATE: We launched <a href="https://documen.so/day1" target="_blank">teams</a> and the early adopters plan will be replaced by the new teams pricing as soon as all availible early adopters seats are filled.
|
|
||||||
|
|
||||||
## Community-Driven Development
|
## Community-Driven Development
|
||||||
|
|
||||||
As we ramp up hiring and development speed for Documenso, I want to discuss how we plan to build its core version.
|
As we ramp up hiring and development speed for Documenso, I want to discuss how we plan to build its core version.
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
---
|
|
||||||
title: Launch Week II - Day 1 - Teams
|
|
||||||
description: Teams for Documenso are here. And they come free for early adopters!
|
|
||||||
authorName: 'Timur Ercan'
|
|
||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
|
||||||
authorRole: 'Co-Founder'
|
|
||||||
date: 2024-02-26
|
|
||||||
tags:
|
|
||||||
- Launch Week
|
|
||||||
- Teams
|
|
||||||
- Early Adopter Perks
|
|
||||||
---
|
|
||||||
|
|
||||||
<video
|
|
||||||
id="vid"
|
|
||||||
width="100%"
|
|
||||||
src="https://github.com/documenso/design/assets/1309312/12a85ec7-20bb-4813-9714-e4da42c9cfba"
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
></video>
|
|
||||||
|
|
||||||
> TLDR; Docucmenso now supports teams that share documents, templates and a team mail address. Early Adopter get UNLIMITED<sup>1</sup> Users.
|
|
||||||
|
|
||||||
## Kicking off Launch Week II - "Connected"
|
|
||||||
|
|
||||||
The day has come! Roughly 5 months after kicked off our first launch week with open sourcing our design and Malfunction Mania, Launch Week #2 is here 🎉 This Launch Week's theme is "connected", since this is all about connecting humans, machines and documents.
|
|
||||||
|
|
||||||
Working with documents and getting that signature is a team sport. This is why we are kicking it off today with a very long-awaited feature: Documenso now supports teams!
|
|
||||||
|
|
||||||
## Introducing Teams for Documenso
|
|
||||||
|
|
||||||
You can now create teams next to your personal account: Simply invite your colleagues, and you can include everyone you like in working with your documents. With teams, you can:
|
|
||||||
|
|
||||||
- Send unlimited signature requests with unlimited recipients
|
|
||||||
- Create, view, edit and sign documents owned by the team
|
|
||||||
- Define a dedicated team email, to receive signing requests into a team inbox for the owner to sign
|
|
||||||
- Manage team roles: Member (Create+Edit), Managers (+Manage Team Members), Owner (+Transfer Team +Delete Team + Sign Documents sent to team email)
|
|
||||||
|
|
||||||
## Pricing
|
|
||||||
|
|
||||||
Together with Teams, we are announcing the new teams pricing:
|
|
||||||
|
|
||||||
- $10 per seat per month
|
|
||||||
- 5 seats minimum
|
|
||||||
- You can add seats dynamically as needed
|
|
||||||
|
|
||||||
This pricing will take effect, as soon as the early adopter seats run out. Want to check out teams: [https://documen.so/teams](https://documen.so/teams).
|
|
||||||
|
|
||||||
## Early Adopter Perks
|
|
||||||
|
|
||||||
There is one more point on pricing I have been looking forward to for a long time:
|
|
||||||
|
|
||||||
All early adopter plans now include **UNLIMITED teams and users**<sup>1</sup> . We appreciate your support so far very much, and I'm happy to announce this first of more early adopter perks to come. We have roughly 48 early adopter plans left, so if you plan to onboard your team, now is a great time to [grab your early adopter seat.](https://documen.so/claim-early-adopters-plan)
|
|
||||||
|
|
||||||
We are eager to hear from all teams users how you like this addition and what we can add to make it even better. Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here, and we would love to hear from you :)
|
|
||||||
|
|
||||||
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 1! Teams just dropped. Check it out https://documen.so/day1 🚀"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
|
|
||||||
|
|
||||||
Best from Hamburg\
|
|
||||||
Timur
|
|
||||||
|
|
||||||
\
|
|
||||||
[1] Within reason. If you are unsure what that means, feel free to contact hi@documenso.com and ask for clarification if it's more than 100.
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
---
|
|
||||||
title: Launch Week II - Day 2 - Templates
|
|
||||||
description: Templates help you prepare regular documents faster. And you can share them with your team!
|
|
||||||
authorName: 'Timur Ercan'
|
|
||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
|
||||||
authorRole: 'Co-Founder'
|
|
||||||
date: 2024-02-27
|
|
||||||
tags:
|
|
||||||
- Launch Week
|
|
||||||
- Templates
|
|
||||||
---
|
|
||||||
|
|
||||||
<video
|
|
||||||
id="vid"
|
|
||||||
width="100%"
|
|
||||||
src="https://github.com/documenso/design/assets/1309312/c9504db1-26b7-4033-88ed-a95cabd02e92"
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
></video>
|
|
||||||
|
|
||||||
> TLDR; You can now reuse documents via templates. More field types coming soon as well.
|
|
||||||
|
|
||||||
## Introducing Templates
|
|
||||||
|
|
||||||
It's day 2 of Launch Week, everybody 🙌 After introducing [Teams](https://documenso.com/blog/launch-week-2-day-1) yesterday, today we are looking at making Documenso faster for daily use:
|
|
||||||
We are launching templates for Documenso! Templates are an easy way to reuse documents you send out often with just a few clicks. With templates, you can:
|
|
||||||
|
|
||||||
<figure>
|
|
||||||
<MdxNextImage
|
|
||||||
src="/blog/quickfill.png"
|
|
||||||
width="1260"
|
|
||||||
height="630"
|
|
||||||
alt="Template recipients quick fill view"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<figcaption className="text-center">
|
|
||||||
Quickly fill out recipients, when creating from a template
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
- Save often-uploaded documents for reuse
|
|
||||||
- Pre-define fields, so you just have to send the document
|
|
||||||
- Quickly fill out recipients and roles for new documents
|
|
||||||
- Share templates with your team to make working together even easier
|
|
||||||
|
|
||||||
<figure>
|
|
||||||
<MdxNextImage
|
|
||||||
src="/blog/template.png"
|
|
||||||
width="1260"
|
|
||||||
height="630"
|
|
||||||
alt="Create from template view"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<figcaption className="text-center">
|
|
||||||
POV: You are a diligent german and create custom receipts with Documenso
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
## Pricing
|
|
||||||
|
|
||||||
Templates are **included in all Documenso Plans!** That includes our free tier: The limit of 5 documents per month still applies, but you are free to reach it with less friction using templates. Sharing templates with other users is only possible with the teams plan. If you want to share templates with people not in your team, we might have something coming up later this week 👀
|
|
||||||
|
|
||||||
## What's Next for Templates
|
|
||||||
|
|
||||||
We have a lot of great stuff coming up for templates as well:
|
|
||||||
|
|
||||||
- More Field Types are in the pipeline
|
|
||||||
- Sharing Templates Externally 👀
|
|
||||||
|
|
||||||
Check out templates [here](https://documen.so/templates) and let us know what you think and what we can improve. Which field types are you missing? Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
|
|
||||||
|
|
||||||
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 2! They just launched templates, and I'm pumped 🎉🚀🚀🚀. Check it out https://documen.so/day2"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
|
|
||||||
|
|
||||||
Best from Hamburg\
|
|
||||||
Timur
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
title: Launch Week II - Day 3 - API
|
|
||||||
description: Documenso's mission is to create a plattform developers all around the world can build upon. Today we are releasing the first version of our public API, included in all plans!
|
|
||||||
authorName: 'Timur Ercan'
|
|
||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
|
||||||
authorRole: 'Co-Founder'
|
|
||||||
date: 2024-02-28
|
|
||||||
tags:
|
|
||||||
- Launch Week
|
|
||||||
- API
|
|
||||||
---
|
|
||||||
|
|
||||||
<video
|
|
||||||
id="vid"
|
|
||||||
width="100%"
|
|
||||||
src="https://github.com/documenso/design/assets/1309312/cb74d6cb-a127-4cac-a166-ad6b56c6140d"
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
></video>
|
|
||||||
|
|
||||||
> TLDR; The public API is now availible for all plans.
|
|
||||||
|
|
||||||
## Introducing the public Documenso API
|
|
||||||
|
|
||||||
Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform that developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. Since this is the first version, we focused on the basics. With the new API you can:
|
|
||||||
|
|
||||||
- Get Documents (Individual or all Accessible)
|
|
||||||
- Upload Documents
|
|
||||||
- Delete Documents
|
|
||||||
- Create Documents from Templates
|
|
||||||
- Trigger Sending Documents for Singing
|
|
||||||
|
|
||||||
You can check out the detailed API documentation here:
|
|
||||||
|
|
||||||
> API DOCUMENTATION: [https://app.documenso.com/api/v1/openapi](https://app.documenso.com/api/v1/openapi)
|
|
||||||
|
|
||||||
## Pricing
|
|
||||||
|
|
||||||
We are building Documenso to be an open and extendable platform; therefore the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost, as can teams. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs.
|
|
||||||
|
|
||||||
> Try the API here for free: [https://documen.so/api](https://documen.so/api)
|
|
||||||
|
|
||||||
## What's next for the API
|
|
||||||
|
|
||||||
You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better?
|
|
||||||
|
|
||||||
Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
|
|
||||||
|
|
||||||
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 3! The public API is here 👀 Check it out https://documen.so/day3"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
|
|
||||||
|
|
||||||
Best from Hamburg\
|
|
||||||
Timur
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 132 KiB |
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { usePathname } from 'next/navigation';
|
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';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { Footer } from '~/components/(marketing)/footer';
|
import { Footer } from '~/components/(marketing)/footer';
|
||||||
@@ -21,10 +17,6 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
const [scrollY, setScrollY] = useState(0);
|
const [scrollY, setScrollY] = useState(0);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const { getFlag } = useFeatureFlags();
|
|
||||||
|
|
||||||
const showProfilesAnnouncementBar = getFlag('marketing_profiles_announcement_bar');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
setScrollY(window.scrollY);
|
setScrollY(window.scrollY);
|
||||||
@@ -46,31 +38,6 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
'bg-background/50 backdrop-blur-md': scrollY > 5,
|
'bg-background/50 backdrop-blur-md': scrollY > 5,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{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">
|
|
||||||
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">
|
|
||||||
<a
|
|
||||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=marketing-announcement-bar`}
|
|
||||||
className="bg-background text-foreground rounded-md px-2.5 py-1 text-xs font-medium duration-300"
|
|
||||||
>
|
|
||||||
Claim Now
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default async function SinglePlayerModeSuccessPage({
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const signatures = await getRecipientSignatures({ recipientId: document.Recipient[0].id });
|
const signatures = await getRecipientSignatures({ recipientId: document.Recipient.id });
|
||||||
|
|
||||||
return <SinglePlayerModeSuccess document={document} signatures={signatures} />;
|
return <SinglePlayerModeSuccess document={document} signatures={signatures} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export const SinglePlayerClient = () => {
|
|||||||
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
|
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
|
||||||
Create a{' '}
|
Create a{' '}
|
||||||
<Link
|
<Link
|
||||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
|
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="hover:text-foreground/80 font-semibold transition-colors"
|
className="hover:text-foreground/80 font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
@@ -256,7 +256,6 @@ export const SinglePlayerClient = () => {
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onSignSubmit}
|
onSubmit={onSignSubmit}
|
||||||
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
||||||
requireCustomText={Boolean(fields.find((field) => field.type === 'TEXT'))}
|
|
||||||
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { Suspense } from 'react';
|
|||||||
|
|
||||||
import { Caveat, Inter } from 'next/font/google';
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
|
|
||||||
import { PublicEnvScript } from 'next-runtime-env';
|
|
||||||
|
|
||||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
||||||
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||||
@@ -64,7 +62,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<PublicEnvScript />
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
|||||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
className="rounded-full bg-transparent backdrop-blur-sm"
|
||||||
onClick={onSignUpClick}
|
onClick={onSignUpClick}
|
||||||
>
|
>
|
||||||
Claim Community Plan
|
Get the Early Adopters Plan
|
||||||
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
|
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
||||||
$30/mo
|
$30/mo. forever!
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import Link from 'next/link';
|
|||||||
import LogoImage from '@documenso/assets/logo.png';
|
import LogoImage from '@documenso/assets/logo.png';
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { HamburgerMenu } from './mobile-hamburger';
|
import { HamburgerMenu } from './mobile-hamburger';
|
||||||
import { MobileNavigation } from './mobile-navigation';
|
import { MobileNavigation } from './mobile-navigation';
|
||||||
@@ -69,18 +68,12 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="https://app.documenso.com/signin?utm_source=marketing-header"
|
href="https://app.documenso.com/signin"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Button className="rounded-full" size="sm" asChild>
|
|
||||||
<Link href="https://app.documenso.com/signup?utm_source=marketing-header" target="_blank">
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HamburgerMenu
|
<HamburgerMenu
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import type { Variants } from 'framer-motion';
|
import { Variants, motion } from 'framer-motion';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
import { LuGithub } from 'react-icons/lu';
|
import { LuGithub } from 'react-icons/lu';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@@ -114,9 +113,9 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
className="rounded-full bg-transparent backdrop-blur-sm"
|
||||||
onClick={onSignUpClick}
|
onClick={onSignUpClick}
|
||||||
>
|
>
|
||||||
Claim Community Plan
|
Get the Early Adopters Plan
|
||||||
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
|
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
||||||
$30/mo
|
$30/mo. forever!
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -225,7 +224,8 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
<span className="bg-primary text-black">
|
<span className="bg-primary text-black">
|
||||||
(in a non-legally binding, but heartfelt way)
|
(in a non-legally binding, but heartfelt way)
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
and lock in the community plan for forever, including everything we build this year.
|
and lock in the early supporter plan for forever, including everything we build this
|
||||||
|
year.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex h-24 items-center">
|
<div className="flex h-24 items-center">
|
||||||
|
|||||||
@@ -47,13 +47,9 @@ export const MENU_NAVIGATION_LINKS = [
|
|||||||
text: 'Privacy',
|
text: 'Privacy',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: 'https://app.documenso.com/signin?utm_source=marketing-header',
|
href: 'https://app.documenso.com/signin',
|
||||||
text: 'Sign in',
|
text: 'Sign in',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: 'https://app.documenso.com/signup?utm_source=marketing-header',
|
|
||||||
text: 'Sign up',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
|
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
|
||||||
|
|||||||
@@ -83,11 +83,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button className="rounded-full text-base" asChild>
|
<Button className="rounded-full text-base" asChild>
|
||||||
<Link
|
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank" className="mt-6">
|
||||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-free-plan`}
|
|
||||||
target="_blank"
|
|
||||||
className="mt-6"
|
|
||||||
>
|
|
||||||
Signup Now
|
Signup Now
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -118,31 +114,33 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button className="mt-6 rounded-full text-base" asChild>
|
<Button className="mt-6 rounded-full text-base" asChild>
|
||||||
<Link
|
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}>Signup Now</Link>
|
||||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-community`}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Signup Now
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<div className="mt-8 flex w-full flex-col divide-y">
|
||||||
<p className="text-foreground py-4">
|
<p className="text-foreground py-4 font-medium">
|
||||||
<a
|
{' '}
|
||||||
href="https://documen.so/early-adopters-pricing-page"
|
<a href="https://documenso.com/blog/early-adopters" target="_blank" rel="noreferrer">
|
||||||
target="_blank"
|
The Early Adopter Deal:
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Limited Time Offer: <span className="text-documenso-700">Read More</span>
|
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-foregro‚und py-4">Unlimited Teams</p>
|
<p className="text-foreground py-4">Join the movement</p>
|
||||||
<p className="text-foregro‚und py-4">Unlimited Users</p>
|
<p className="text-foreground py-4">Simple signing solution</p>
|
||||||
<p className="text-foregro‚und py-4">Unlimited Documents per month</p>
|
|
||||||
<p className="text-foreground py-4">Includes all upcoming features</p>
|
|
||||||
<p className="text-foreground py-4">Email, Discord and Slack assistance</p>
|
<p className="text-foreground py-4">Email, Discord and Slack assistance</p>
|
||||||
|
<p className="text-foreground py-4">
|
||||||
|
<strong>
|
||||||
|
{' '}
|
||||||
|
<a
|
||||||
|
href="https://documenso.com/blog/early-adopters"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Includes all upcoming features
|
||||||
|
</a>
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground py-4">Fixed, straightforward pricing</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const SinglePlayerModeSuccess = ({
|
|||||||
|
|
||||||
<SigningCard3D
|
<SigningCard3D
|
||||||
className="mt-8"
|
className="mt-8"
|
||||||
name={document.Recipient[0].name || document.Recipient[0].email}
|
name={document.Recipient.name || document.Recipient.email}
|
||||||
signature={signatures.at(0)}
|
signature={signatures.at(0)}
|
||||||
signingCelebrationImage={signingCelebration}
|
signingCelebrationImage={signingCelebration}
|
||||||
/>
|
/>
|
||||||
@@ -65,7 +65,7 @@ export const SinglePlayerModeSuccess = ({
|
|||||||
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
|
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
|
||||||
<DocumentShareButton
|
<DocumentShareButton
|
||||||
documentId={document.id}
|
documentId={document.id}
|
||||||
token={document.Recipient[0].token}
|
token={document.Recipient.token}
|
||||||
className="flex-1 bg-transparent backdrop-blur-sm"
|
className="flex-1 bg-transparent backdrop-blur-sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export const SinglePlayerModeSuccess = ({
|
|||||||
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
|
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
|
||||||
Create a{' '}
|
Create a{' '}
|
||||||
<Link
|
<Link
|
||||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
|
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
|
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
className="bg-foreground/5 col-span-12 flex flex-col rounded-2xl p-6 lg:col-span-5"
|
className="bg-foreground/5 col-span-12 flex flex-col rounded-2xl p-6 lg:col-span-5"
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<h3 className="text-xl font-semibold">Sign up to Community Plan</h3>
|
<h3 className="text-2xl font-semibold">Sign up for the early adopters plan</h3>
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
with Timur Ercan & Lucas Smith from Documenso
|
with Timur Ercan & Lucas Smith from Documenso
|
||||||
</p>
|
</p>
|
||||||
@@ -208,7 +208,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div key="email">
|
<motion.div key="email">
|
||||||
<label htmlFor="email" className="text-foreground font-medium ">
|
<label htmlFor="email" className="text-foreground text-lg font-semibold lg:text-xl">
|
||||||
What’s your email?
|
What’s your email?
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="your@example.com"
|
placeholder=""
|
||||||
className="bg-background w-full pr-16"
|
className="bg-background w-full pr-16"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onKeyDown={(e) =>
|
onKeyDown={(e) =>
|
||||||
@@ -265,8 +265,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
transform: 'translateX(25%)',
|
transform: 'translateX(25%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label htmlFor="name" className="text-foreground font-medium ">
|
<label
|
||||||
And your name?
|
htmlFor="name"
|
||||||
|
className="text-foreground text-lg font-semibold lg:text-xl"
|
||||||
|
>
|
||||||
|
and your name?
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
|
|||||||
@@ -43,7 +43,6 @@
|
|||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"remeda": "^1.27.1",
|
|
||||||
"sharp": "0.33.1",
|
"sharp": "0.33.1",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { BarChart3, FileStack, Settings, User2, Wallet2 } from 'lucide-react';
|
import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -78,20 +78,6 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
Subscriptions
|
Subscriptions
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'justify-start md:w-full',
|
|
||||||
pathname?.startsWith('/admin/banner') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link href="/admin/site-settings">
|
|
||||||
<Settings className="mr-2 h-5 w-5" />
|
|
||||||
Site Settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import type { TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
|
||||||
import {
|
|
||||||
SITE_SETTINGS_BANNER_ID,
|
|
||||||
ZSiteSettingsBannerSchema,
|
|
||||||
} from '@documenso/lib/server-only/site-settings/schemas/banner';
|
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
|
||||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
|
|
||||||
|
|
||||||
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
|
|
||||||
|
|
||||||
export type BannerFormProps = {
|
|
||||||
banner?: TSiteSettingsBannerSchema;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BannerForm({ banner }: BannerFormProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm<TBannerFormSchema>({
|
|
||||||
resolver: zodResolver(ZBannerFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
id: SITE_SETTINGS_BANNER_ID,
|
|
||||||
enabled: banner?.enabled ?? false,
|
|
||||||
data: {
|
|
||||||
content: banner?.data?.content ?? '',
|
|
||||||
bgColor: banner?.data?.bgColor ?? '#000000',
|
|
||||||
textColor: banner?.data?.textColor ?? '#FFFFFF',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const enabled = form.watch('enabled');
|
|
||||||
|
|
||||||
const { mutateAsync: updateSiteSetting, isLoading: isUpdateSiteSettingLoading } =
|
|
||||||
trpcReact.admin.updateSiteSetting.useMutation();
|
|
||||||
|
|
||||||
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
|
|
||||||
try {
|
|
||||||
await updateSiteSetting({
|
|
||||||
id,
|
|
||||||
enabled,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Banner Updated',
|
|
||||||
description: 'Your banner has been updated successfully.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
|
||||||
toast({
|
|
||||||
title: 'An error occurred',
|
|
||||||
description: err.message,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to update the banner. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold">Site Banner</h2>
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
The site banner is a message that is shown at the top of the site. It can be used to display
|
|
||||||
important information to your users.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
className="mt-4 flex flex-col rounded-md"
|
|
||||||
onSubmit={form.handleSubmit(onBannerUpdate)}
|
|
||||||
>
|
|
||||||
<div className="mt-4 flex flex-col gap-4 md:flex-row">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>Enabled</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<div>
|
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<fieldset
|
|
||||||
className="flex flex-col gap-4 md:flex-row"
|
|
||||||
disabled={!enabled}
|
|
||||||
aria-disabled={!enabled}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="data.bgColor"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Background Color</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<div>
|
|
||||||
<ColorPicker {...field} />
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="data.textColor"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Text Color</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<div>
|
|
||||||
<ColorPicker {...field} />
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset disabled={!enabled} aria-disabled={!enabled}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="data.content"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Content</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Textarea className="h-32 resize-none" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
The content to show in the banner, HTML is allowed
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={isUpdateSiteSettingLoading}
|
|
||||||
className="mt-4 justify-end self-end"
|
|
||||||
>
|
|
||||||
Update Banner
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
|
||||||
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
|
||||||
|
|
||||||
import { BannerForm } from './banner-form';
|
|
||||||
|
|
||||||
// import { BannerForm } from './banner-form';
|
|
||||||
|
|
||||||
export default async function AdminBannerPage() {
|
|
||||||
const banner = await getSiteSettings().then((settings) =>
|
|
||||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader title="Site Settings" subtitle="Manage your site settings here" />
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<BannerForm banner={banner} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
|
||||||
import { useSession } from 'next-auth/react';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type DocumentPageViewButtonProps = {
|
|
||||||
document: Document & {
|
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
Recipient: Recipient[];
|
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
|
||||||
};
|
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email);
|
|
||||||
|
|
||||||
const isRecipient = !!recipient;
|
|
||||||
const isPending = document.status === DocumentStatus.PENDING;
|
|
||||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
|
||||||
const role = recipient?.role;
|
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(document.team?.url);
|
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
const documentWithData = await trpcClient.document.getDocumentById.query({
|
|
||||||
id: document.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentData = documentWithData?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
throw new Error('No document available');
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: documentWithData.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: 'An error occurred while downloading your document.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return match({
|
|
||||||
isRecipient,
|
|
||||||
isPending,
|
|
||||||
isComplete,
|
|
||||||
isSigned,
|
|
||||||
})
|
|
||||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
|
||||||
<Button className="w-full" asChild>
|
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
|
||||||
{match(role)
|
|
||||||
.with(RecipientRole.SIGNER, () => (
|
|
||||||
<>
|
|
||||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
|
||||||
Sign
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.APPROVER, () => (
|
|
||||||
<>
|
|
||||||
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
|
||||||
Approve
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<>
|
|
||||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
|
||||||
View
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
))
|
|
||||||
.with({ isComplete: false }, () => (
|
|
||||||
<Button className="w-full" asChild>
|
|
||||||
<Link href={`${documentsPath}/${document.id}/edit`}>Edit</Link>
|
|
||||||
</Button>
|
|
||||||
))
|
|
||||||
.with({ isComplete: true }, () => (
|
|
||||||
<Button className="w-full" onClick={onDownloadClick}>
|
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
))
|
|
||||||
.otherwise(() => null);
|
|
||||||
};
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Copy, Download, Edit, Loader, MoreHorizontal, 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 { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { ResendDocumentActionItem } from '../_action-items/resend-document';
|
|
||||||
import { DeleteDocumentDialog } from '../delete-document-dialog';
|
|
||||||
import { DuplicateDocumentDialog } from '../duplicate-document-dialog';
|
|
||||||
|
|
||||||
export type DocumentPageViewDropdownProps = {
|
|
||||||
document: Document & {
|
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
Recipient: Recipient[];
|
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
|
||||||
};
|
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email);
|
|
||||||
|
|
||||||
const isOwner = document.User.id === session.user.id;
|
|
||||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
|
||||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
|
||||||
const isDocumentDeletable = isOwner;
|
|
||||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team?.url);
|
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
const documentWithData = await trpcClient.document.getDocumentById.query({
|
|
||||||
id: document.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentData = documentWithData?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: document.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: 'An error occurred while downloading your document.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nonSignedRecipients = document.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="end" forceMount>
|
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
|
||||||
|
|
||||||
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`${documentsPath}/${document.id}/edit`}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isComplete && (
|
|
||||||
<DropdownMenuItem onClick={onDownloadClick}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Download
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
|
||||||
Duplicate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<ResendDocumentActionItem
|
|
||||||
document={document}
|
|
||||||
recipients={nonSignedRecipients}
|
|
||||||
team={team}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DocumentShareButton
|
|
||||||
documentId={document.id}
|
|
||||||
token={isOwner ? undefined : recipient?.token}
|
|
||||||
trigger={({ loading, disabled }) => (
|
|
||||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
|
||||||
Share Signing Card
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
|
|
||||||
{isDocumentDeletable && (
|
|
||||||
<DeleteDocumentDialog
|
|
||||||
id={document.id}
|
|
||||||
status={document.status}
|
|
||||||
documentTitle={document.title}
|
|
||||||
open={isDeleteDialogOpen}
|
|
||||||
onOpenChange={setDeleteDialogOpen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isDuplicateDialogOpen && (
|
|
||||||
<DuplicateDocumentDialog
|
|
||||||
id={document.id}
|
|
||||||
open={isDuplicateDialogOpen}
|
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
|
||||||
team={team}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
|
||||||
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
export type DocumentPageViewInformationProps = {
|
|
||||||
userId: number;
|
|
||||||
document: Document & {
|
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
Recipient: Recipient[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentPageViewInformation = ({
|
|
||||||
document,
|
|
||||||
userId,
|
|
||||||
}: DocumentPageViewInformationProps) => {
|
|
||||||
const isMounted = useIsMounted();
|
|
||||||
|
|
||||||
const { locale } = useLocale();
|
|
||||||
|
|
||||||
const documentInformation = useMemo(() => {
|
|
||||||
let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy');
|
|
||||||
let lastModifiedValue = DateTime.fromJSDate(document.updatedAt).toRelative();
|
|
||||||
|
|
||||||
if (!isMounted) {
|
|
||||||
createdValue = DateTime.fromJSDate(document.createdAt)
|
|
||||||
.setLocale(locale)
|
|
||||||
.toFormat('MMMM d, yyyy');
|
|
||||||
|
|
||||||
lastModifiedValue = DateTime.fromJSDate(document.updatedAt).setLocale(locale).toRelative();
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
description: 'Uploaded by',
|
|
||||||
value: userId === document.userId ? 'You' : document.User.name ?? document.User.email,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: 'Created',
|
|
||||||
value: createdValue,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: 'Last modified',
|
|
||||||
value: lastModifiedValue,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [isMounted, document, locale, userId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
|
||||||
<h1 className="px-4 py-3 font-medium">Information</h1>
|
|
||||||
|
|
||||||
<ul className="divide-y border-t">
|
|
||||||
{documentInformation.map((item) => (
|
|
||||||
<li
|
|
||||||
key={item.description}
|
|
||||||
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
|
|
||||||
>
|
|
||||||
<span className="text-muted-foreground">{item.description}</span>
|
|
||||||
<span>{item.value}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
|
||||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type DocumentPageViewRecentActivityProps = {
|
|
||||||
documentId: number;
|
|
||||||
userId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentPageViewRecentActivity = ({
|
|
||||||
documentId,
|
|
||||||
userId,
|
|
||||||
}: DocumentPageViewRecentActivityProps) => {
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
isLoadingError,
|
|
||||||
refetch,
|
|
||||||
hasNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
|
|
||||||
{
|
|
||||||
documentId,
|
|
||||||
filterForRecentActivity: true,
|
|
||||||
orderBy: {
|
|
||||||
column: 'createdAt',
|
|
||||||
direction: 'asc',
|
|
||||||
},
|
|
||||||
perPage: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
|
||||||
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
|
|
||||||
<h1 className="text-foreground font-medium">Recent activity</h1>
|
|
||||||
|
|
||||||
{/* Can add dropdown menu here for additional options. */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex h-full items-center justify-center py-16">
|
|
||||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoadingError && (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center py-16">
|
|
||||||
<p className="text-foreground/80 text-sm">Unable to load document history</p>
|
|
||||||
<button
|
|
||||||
onClick={async () => refetch()}
|
|
||||||
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
|
||||||
>
|
|
||||||
Click here to retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AnimateGenericFadeInOut>
|
|
||||||
{data && (
|
|
||||||
<ul role="list" className="space-y-6 p-4">
|
|
||||||
{hasNextPage && (
|
|
||||||
<li className="relative flex gap-x-4">
|
|
||||||
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
|
|
||||||
<div className="bg-border w-px" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
|
|
||||||
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={async () => fetchNextPage()}
|
|
||||||
className="text-foreground/70 hover:text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? 'Loading...' : 'Load older activity'}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{documentAuditLogs.length === 0 && (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<p className="text-muted-foreground/70 text-sm">No recent activity</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{documentAuditLogs.map((auditLog, auditLogIndex) => (
|
|
||||||
<li key={auditLog.id} className="relative flex gap-x-4">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
auditLogIndex === documentAuditLogs.length - 1 ? 'h-6' : '-bottom-6',
|
|
||||||
'absolute left-0 top-0 flex w-6 justify-center',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="bg-border w-px" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
|
|
||||||
{match(auditLog.type)
|
|
||||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
|
|
||||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
|
||||||
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
|
|
||||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
|
||||||
<CheckIcon className="h-3 w-3" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
|
|
||||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
|
||||||
<MailOpen className="h-3 w-3" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground dark:text-muted-foreground/70 flex-auto py-0.5 text-xs leading-5">
|
|
||||||
<span className="text-foreground font-medium">
|
|
||||||
{formatDocumentAuditLogAction(auditLog, userId).prefix}
|
|
||||||
</span>{' '}
|
|
||||||
{formatDocumentAuditLogAction(auditLog, userId).description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
|
|
||||||
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
|
|
||||||
</time>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</AnimateGenericFadeInOut>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
|
||||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
|
||||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
|
|
||||||
export type DocumentPageViewRecipientsProps = {
|
|
||||||
document: Document & {
|
|
||||||
Recipient: Recipient[];
|
|
||||||
};
|
|
||||||
documentRootPath: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentPageViewRecipients = ({
|
|
||||||
document,
|
|
||||||
documentRootPath,
|
|
||||||
}: DocumentPageViewRecipientsProps) => {
|
|
||||||
const recipients = document.Recipient;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
|
||||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
|
||||||
<h1 className="text-foreground font-medium">Recipients</h1>
|
|
||||||
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
|
||||||
<Link
|
|
||||||
href={`${documentRootPath}/${document.id}/edit?step=signers`}
|
|
||||||
title="Modify recipients"
|
|
||||||
className="flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
{recipients.length === 0 ? (
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<PenIcon className="ml-2 h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="text-muted-foreground divide-y border-t">
|
|
||||||
{recipients.length === 0 && (
|
|
||||||
<li className="flex flex-col items-center justify-center py-6 text-sm">No recipients</li>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{recipients.map((recipient) => (
|
|
||||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
|
||||||
<AvatarWithText
|
|
||||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
|
||||||
secondaryText={
|
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
|
||||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
|
||||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
|
||||||
<Badge variant="default">
|
|
||||||
{match(recipient.role)
|
|
||||||
.with(RecipientRole.APPROVER, () => (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="mr-1 h-3 w-3" />
|
|
||||||
Approved
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.CC, () =>
|
|
||||||
document.status === DocumentStatus.COMPLETED ? (
|
|
||||||
<>
|
|
||||||
<MailIcon className="mr-1 h-3 w-3" />
|
|
||||||
Sent
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="mr-1 h-3 w-3" />
|
|
||||||
Ready
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
.with(RecipientRole.SIGNER, () => (
|
|
||||||
<>
|
|
||||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
|
||||||
Signed
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.VIEWER, () => (
|
|
||||||
<>
|
|
||||||
<MailOpenIcon className="mr-1 h-3 w-3" />
|
|
||||||
Viewed
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
|
||||||
Pending
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,34 +1,22 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
|
||||||
import type { Team } from '@documenso/prisma/client';
|
import type { Team } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
import {
|
|
||||||
DocumentStatus as DocumentStatusComponent,
|
|
||||||
FRIENDLY_STATUS_MAP,
|
|
||||||
} from '~/components/formatter/document-status';
|
|
||||||
|
|
||||||
import { DocumentPageViewButton } from './document-page-view-button';
|
|
||||||
import { DocumentPageViewDropdown } from './document-page-view-dropdown';
|
|
||||||
import { DocumentPageViewInformation } from './document-page-view-information';
|
|
||||||
import { DocumentPageViewRecentActivity } from './document-page-view-recent-activity';
|
|
||||||
import { DocumentPageViewRecipients } from './document-page-view-recipients';
|
|
||||||
|
|
||||||
export type DocumentPageViewProps = {
|
export type DocumentPageViewProps = {
|
||||||
params: {
|
params: {
|
||||||
@@ -56,10 +44,6 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
const isDocumentHistoryEnabled = await getServerComponentFlag(
|
|
||||||
'app_document_page_view_history_sheet',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
if (!document || !document.documentData) {
|
||||||
redirect(documentRootPath);
|
redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
@@ -83,16 +67,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
documentMeta.password = securePassword;
|
documentMeta.password = securePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipients = await getRecipientsForDocument({
|
const [recipients, fields] = await Promise.all([
|
||||||
documentId,
|
getRecipientsForDocument({
|
||||||
teamId: team?.id,
|
documentId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
}),
|
||||||
|
getFieldsForDocument({
|
||||||
const documentWithRecipients = {
|
documentId,
|
||||||
...document,
|
userId: user.id,
|
||||||
Recipient: recipients,
|
}),
|
||||||
};
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
@@ -101,105 +85,47 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-row justify-between">
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
<div>
|
{document.title}
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
</h1>
|
||||||
{document.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
<DocumentStatusComponent
|
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||||
inheritColor
|
|
||||||
status={document.status}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
{recipients.length > 0 && (
|
||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isDocumentHistoryEnabled && (
|
|
||||||
<div className="self-end">
|
|
||||||
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Clock9 className="mr-1.5 h-4 w-4" />
|
|
||||||
Document history
|
|
||||||
</Button>
|
|
||||||
</DocumentHistorySheet>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||||
<Card
|
<EditDocumentForm
|
||||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
className="mt-8"
|
||||||
gradient
|
document={document}
|
||||||
>
|
user={user}
|
||||||
<CardContent className="p-2">
|
documentMeta={documentMeta}
|
||||||
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
|
recipients={recipients}
|
||||||
</CardContent>
|
fields={fields}
|
||||||
</Card>
|
documentData={documentData}
|
||||||
|
documentRootPath={documentRootPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||||
<div className="space-y-6">
|
<div className="mx-auto mt-12 max-w-2xl">
|
||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
<LazyPDFViewer
|
||||||
<div className="flex flex-row items-center justify-between px-4">
|
document={document}
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
key={documentData.id}
|
||||||
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
|
documentMeta={documentMeta}
|
||||||
</h3>
|
documentData={documentData}
|
||||||
|
/>
|
||||||
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 px-4 text-sm ">
|
|
||||||
{match(document.status)
|
|
||||||
.with(
|
|
||||||
DocumentStatus.COMPLETED,
|
|
||||||
() => 'This document has been signed by all recipients',
|
|
||||||
)
|
|
||||||
.with(
|
|
||||||
DocumentStatus.DRAFT,
|
|
||||||
() => 'This document is currently a draft and has not been sent',
|
|
||||||
)
|
|
||||||
.with(DocumentStatus.PENDING, () => {
|
|
||||||
const pendingRecipients = recipients.filter(
|
|
||||||
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
|
||||||
);
|
|
||||||
|
|
||||||
return `Waiting on ${pendingRecipients.length} recipient${
|
|
||||||
pendingRecipients.length > 1 ? 's' : ''
|
|
||||||
}`;
|
|
||||||
})
|
|
||||||
.exhaustive()}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 border-t px-4 pt-4">
|
|
||||||
<DocumentPageViewButton document={documentWithRecipients} team={team} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Document information section. */}
|
|
||||||
<DocumentPageViewInformation document={documentWithRecipients} userId={user.id} />
|
|
||||||
|
|
||||||
{/* Recipients section. */}
|
|
||||||
<DocumentPageViewRecipients
|
|
||||||
document={documentWithRecipients}
|
|
||||||
documentRootPath={documentRootPath}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Recent activity section. */}
|
|
||||||
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,16 +2,10 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import {
|
import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
type DocumentData,
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
type DocumentMeta,
|
|
||||||
DocumentStatus,
|
|
||||||
type Field,
|
|
||||||
type Recipient,
|
|
||||||
type User,
|
|
||||||
} from '@documenso/prisma/client';
|
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -30,8 +24,6 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: User;
|
||||||
@@ -57,10 +49,12 @@ export const EditDocumentForm = ({
|
|||||||
documentRootPath,
|
documentRootPath,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const team = useOptionalCurrentTeam();
|
// controlled stepper state
|
||||||
|
const [step, setStep] = useState<EditDocumentStep>(
|
||||||
|
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
|
||||||
|
);
|
||||||
|
|
||||||
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
||||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
||||||
@@ -92,30 +86,11 @@ export const EditDocumentForm = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const [step, setStep] = useState<EditDocumentStep>(() => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
|
|
||||||
|
|
||||||
let initialStep: EditDocumentStep =
|
|
||||||
document.status === DocumentStatus.DRAFT ? 'title' : 'signers';
|
|
||||||
|
|
||||||
if (
|
|
||||||
searchParamStep &&
|
|
||||||
documentFlow[searchParamStep] !== undefined &&
|
|
||||||
!(recipients.length === 0 && (searchParamStep === 'subject' || searchParamStep === 'fields'))
|
|
||||||
) {
|
|
||||||
initialStep = searchParamStep;
|
|
||||||
}
|
|
||||||
|
|
||||||
return initialStep;
|
|
||||||
});
|
|
||||||
|
|
||||||
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
||||||
try {
|
try {
|
||||||
// Custom invocation server action
|
// Custom invocation server action
|
||||||
await addTitle({
|
await addTitle({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
teamId: team?.id,
|
|
||||||
title: data.title,
|
title: data.title,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,7 +113,6 @@ export const EditDocumentForm = ({
|
|||||||
// Custom invocation server action
|
// Custom invocation server action
|
||||||
await addSigners({
|
await addSigners({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
teamId: team?.id,
|
|
||||||
signers: data.signers,
|
signers: data.signers,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,7 +156,6 @@ export const EditDocumentForm = ({
|
|||||||
try {
|
try {
|
||||||
await sendDocument({
|
await sendDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
teamId: team?.id,
|
|
||||||
meta: {
|
meta: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import type { Team } from '@documenso/prisma/client';
|
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
|
||||||
|
|
||||||
export type DocumentEditPageViewProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
team?: Team;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentEditPageView = async ({ params, team }: DocumentEditPageViewProps) => {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
const documentId = Number(id);
|
|
||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team?.url);
|
|
||||||
|
|
||||||
if (!documentId || Number.isNaN(documentId)) {
|
|
||||||
redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const document = await getDocumentById({
|
|
||||||
id: documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
|
||||||
redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.status === InternalDocumentStatus.COMPLETED) {
|
|
||||||
redirect(`${documentRootPath}/${documentId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { documentData, documentMeta } = document;
|
|
||||||
|
|
||||||
if (documentMeta?.password) {
|
|
||||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
|
||||||
|
|
||||||
if (!key) {
|
|
||||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
|
||||||
}
|
|
||||||
|
|
||||||
const securePassword = Buffer.from(
|
|
||||||
symmetricDecrypt({
|
|
||||||
key,
|
|
||||||
data: documentMeta.password,
|
|
||||||
}),
|
|
||||||
).toString('utf-8');
|
|
||||||
|
|
||||||
documentMeta.password = securePassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
|
||||||
getRecipientsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
}),
|
|
||||||
getFieldsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
|
||||||
Documents
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
|
||||||
<div className="text-muted-foreground flex items-center">
|
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
|
||||||
</StackAvatarsWithTooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EditDocumentForm
|
|
||||||
className="mt-8"
|
|
||||||
document={document}
|
|
||||||
user={user}
|
|
||||||
documentMeta={documentMeta}
|
|
||||||
recipients={recipients}
|
|
||||||
fields={fields}
|
|
||||||
documentData={documentData}
|
|
||||||
documentRootPath={documentRootPath}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { DocumentEditPageView } from './document-edit-page-view';
|
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DocumentEditPage({ params }: DocumentPageProps) {
|
|
||||||
return <DocumentEditPageView params={params} />;
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
|
||||||
import { UAParser } from 'ua-parser-js';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
|
||||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
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';
|
|
||||||
|
|
||||||
export type DocumentLogsDataTableProps = {
|
|
||||||
documentId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dateFormat: DateTimeFormatOptions = {
|
|
||||||
...DateTime.DATETIME_SHORT,
|
|
||||||
hourCycle: 'h12',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
|
|
||||||
const parser = new UAParser();
|
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
|
||||||
Object.fromEntries(searchParams ?? []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, isInitialLoading, isLoadingError } =
|
|
||||||
trpc.document.findDocumentAuditLogs.useQuery(
|
|
||||||
{
|
|
||||||
documentId,
|
|
||||||
page: parsedSearchParams.page,
|
|
||||||
perPage: parsedSearchParams.perPage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const uppercaseFistLetter = (text: string) => {
|
|
||||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'Time',
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'User',
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.name || row.original.email ? (
|
|
||||||
<div>
|
|
||||||
{row.original.name && (
|
|
||||||
<p className="truncate" title={row.original.name}>
|
|
||||||
{row.original.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{row.original.email && (
|
|
||||||
<p className="truncate" title={row.original.email}>
|
|
||||||
{row.original.email}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p>N/A</p>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Action',
|
|
||||||
accessorKey: 'type',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span>
|
|
||||||
{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'IP Address',
|
|
||||||
accessorKey: 'ipAddress',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Browser',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
if (!row.original.userAgent) {
|
|
||||||
return 'N/A';
|
|
||||||
}
|
|
||||||
|
|
||||||
parser.setUA(row.original.userAgent);
|
|
||||||
|
|
||||||
const result = parser.getResult();
|
|
||||||
|
|
||||||
return result.browser.name ?? 'N/A';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
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>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-1/2 py-4 pr-4">
|
|
||||||
<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>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-10 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-10 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
|
||||||
</DataTable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
import { ChevronLeft, DownloadIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
|
||||||
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 { DocumentLogsDataTable } from './document-logs-data-table';
|
|
||||||
|
|
||||||
export type DocumentLogsPageViewProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
team?: Team;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
const documentId = Number(id);
|
|
||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team?.url);
|
|
||||||
|
|
||||||
if (!documentId || Number.isNaN(documentId)) {
|
|
||||||
redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const [document, recipients] = await Promise.all([
|
|
||||||
getDocumentById({
|
|
||||||
id: documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
}).catch(() => null),
|
|
||||||
getRecipientsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
|
||||||
redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentInformation: { description: string; value: string }[] = [
|
|
||||||
{
|
|
||||||
description: 'Document title',
|
|
||||||
value: document.title,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: 'Document ID',
|
|
||||||
value: document.id.toString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: 'Document status',
|
|
||||||
value: FRIENDLY_STATUS_MAP[document.status].label,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: 'Created by',
|
|
||||||
value: document.User.name ?? document.User.email,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: 'Date created',
|
|
||||||
value: document.createdAt.toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: 'Last updated',
|
|
||||||
value: document.updatedAt.toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: 'Time zone',
|
|
||||||
value: document.documentMeta?.timezone ?? 'N/A',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatRecipientText = (recipient: Recipient) => {
|
|
||||||
let text = recipient.email;
|
|
||||||
|
|
||||||
if (recipient.name) {
|
|
||||||
text = `${recipient.name} (${recipient.email})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${text} - ${recipient.role}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<Link
|
|
||||||
href={`${documentRootPath}/${document.id}`}
|
|
||||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
|
||||||
Document
|
|
||||||
</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 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>
|
|
||||||
|
|
||||||
<Button className="w-full sm:w-auto">
|
|
||||||
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
|
||||||
Download PDF
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="mt-6">
|
|
||||||
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
|
||||||
{documentInformation.map((info, i) => (
|
|
||||||
<div className="text-foreground text-sm" key={i}>
|
|
||||||
<h3 className="font-semibold">{info.description}</h3>
|
|
||||||
<p className="text-muted-foreground">{info.value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="text-foreground text-sm">
|
|
||||||
<h3 className="font-semibold">Recipients</h3>
|
|
||||||
<ul className="text-muted-foreground list-inside list-disc">
|
|
||||||
{recipients.map((recipient) => (
|
|
||||||
<li key={`recipient-${recipient.id}`}>
|
|
||||||
<span className="-ml-2">{formatRecipientText(recipient)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-6">
|
|
||||||
<DocumentLogsDataTable documentId={document.id} />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { DocumentLogsPageView } from './document-logs-page-view';
|
|
||||||
|
|
||||||
export type DocumentsLogsPageProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
|
|
||||||
return <DocumentLogsPageView params={params} />;
|
|
||||||
}
|
|
||||||
@@ -118,7 +118,7 @@ export const ResendDocumentActionItem = ({
|
|||||||
|
|
||||||
<DialogContent className="sm:max-w-sm" hideClose>
|
<DialogContent className="sm:max-w-sm" hideClose>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle asChild>
|
<DialogTitle>
|
||||||
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
|||||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||||
() => (
|
() => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
<Link href={`${documentsPath}/${row.id}`}>
|
||||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
<Link href={`${documentsPath}/${row.id}`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
@@ -193,7 +193,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
documentTitle={row.title}
|
documentTitle={row.title}
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
teamId={team?.id}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
|
|||||||
@@ -5,19 +5,16 @@ import Link from 'next/link';
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
export type DataTableTitleProps = {
|
export type DataTableTitleProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
team: Pick<Team, 'url'> | null;
|
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
};
|
};
|
||||||
teamUrl?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -28,18 +25,14 @@ export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
|||||||
|
|
||||||
const isOwner = row.User.id === session.user.id;
|
const isOwner = row.User.id === session.user.id;
|
||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
|
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
|
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isOwner,
|
isOwner,
|
||||||
isRecipient,
|
isRecipient,
|
||||||
isCurrentTeamDocument,
|
|
||||||
})
|
})
|
||||||
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
.with({ isOwner: true }, () => (
|
||||||
<Link
|
<Link
|
||||||
href={`${documentsPath}/${row.id}`}
|
href={`/documents/${row.id}`}
|
||||||
title={row.title}
|
title={row.title}
|
||||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const DocumentsDataTable = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sender',
|
id: 'sender',
|
||||||
|
|||||||
@@ -16,13 +16,12 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type DeleteDocumentDialogProps = {
|
type DeleteDraftDocumentDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
status: DocumentStatus;
|
status: DocumentStatus;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
teamId?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteDocumentDialog = ({
|
export const DeleteDocumentDialog = ({
|
||||||
@@ -31,8 +30,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
status,
|
status,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
teamId,
|
}: DeleteDraftDocumentDialogProps) => {
|
||||||
}: DeleteDocumentDialogProps) => {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -63,7 +61,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteDocument({ id, teamId });
|
await deleteDocument({ id, status });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: (newId) => {
|
onSuccess: (newId) => {
|
||||||
router.push(`${documentsPath}/${newId}/edit`);
|
router.push(`${documentsPath}/${newId}`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document Duplicated',
|
title: 'Document Duplicated',
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
|
|
||||||
import type { DocumentsPageViewProps } from './documents-page-view';
|
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||||
import { DocumentsPageView } from './documents-page-view';
|
import { DocumentsPageView } from './documents-page-view';
|
||||||
import { UpcomingProfileClaimTeaser } from './upcoming-profile-claim-teaser';
|
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
searchParams?: DocumentsPageViewProps['searchParams'];
|
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||||
@@ -14,12 +11,6 @@ export const metadata: Metadata = {
|
|||||||
title: 'Documents',
|
title: 'Documents',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
return <DocumentsPageView searchParams={searchParams} />;
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<UpcomingProfileClaimTeaser user={user} />
|
|
||||||
<DocumentsPageView searchParams={searchParams} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
|
|
||||||
|
|
||||||
export type UpcomingProfileClaimTeaserProps = {
|
|
||||||
user: User;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [claimed, setClaimed] = useState(false);
|
|
||||||
|
|
||||||
const onOpenChange = useCallback(
|
|
||||||
(open: boolean) => {
|
|
||||||
if (!open && !claimed) {
|
|
||||||
toast({
|
|
||||||
title: 'Claim your profile later',
|
|
||||||
description: 'You can claim your profile later on by going to your profile settings!',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(open);
|
|
||||||
localStorage.setItem('app.hasShownProfileClaimDialog', 'true');
|
|
||||||
},
|
|
||||||
[claimed, toast],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hasShownProfileClaimDialog =
|
|
||||||
localStorage.getItem('app.hasShownProfileClaimDialog') === 'true';
|
|
||||||
|
|
||||||
if (!user.url && !hasShownProfileClaimDialog) {
|
|
||||||
onOpenChange(true);
|
|
||||||
}
|
|
||||||
}, [onOpenChange, user.url]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ClaimPublicProfileDialogForm
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
onClaimed={() => setClaimed(true)}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -83,7 +83,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
|
||||||
import { Banner } from '~/components/(dashboard)/layout/banner';
|
|
||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
@@ -38,8 +37,6 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
<LimitsProvider>
|
<LimitsProvider>
|
||||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
|
|
||||||
<Banner />
|
|
||||||
|
|
||||||
<Header user={user} teams={teams} />
|
<Header user={user} teams={teams} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
|
|
||||||
|
|
||||||
export type ClaimProfileAlertDialogProps = {
|
|
||||||
className?: string;
|
|
||||||
user: User;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClaimProfileAlertDialog = ({ className, user }: ClaimProfileAlertDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Alert
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col items-center justify-between gap-4 p-6 md:flex-row',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
variant="neutral"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<AlertTitle>Claim your profile</AlertTitle>
|
|
||||||
<AlertDescription className="mr-2">
|
|
||||||
Profiles are coming soon! Claim your profile username now to reserve your corner of the
|
|
||||||
signing revolution.
|
|
||||||
</AlertDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Button onClick={() => setOpen(true)}>Claim Now</Button>
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<ClaimPublicProfileDialogForm open={open} onOpenChange={setOpen} user={user} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { signOut } from 'next-auth/react';
|
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type DeleteAccountDialogProps = {
|
|
||||||
className?: string;
|
|
||||||
user: User;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
|
||||||
|
|
||||||
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
|
|
||||||
trpc.profile.deleteAccount.useMutation();
|
|
||||||
|
|
||||||
const onDeleteAccount = async () => {
|
|
||||||
try {
|
|
||||||
await deleteAccount();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Account deleted',
|
|
||||||
description: 'Your account has been deleted successfully.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await signOut({ callbackUrl: '/' });
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
|
||||||
toast({
|
|
||||||
title: 'An error occurred',
|
|
||||||
description: err.message,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
description:
|
|
||||||
err.message ??
|
|
||||||
'We encountered an unknown error while attempting to delete your account. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<Alert
|
|
||||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
|
|
||||||
variant="neutral"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<AlertTitle>Delete Account</AlertTitle>
|
|
||||||
<AlertDescription className="mr-2">
|
|
||||||
Delete your account and all its contents, including completed documents. This action is
|
|
||||||
irreversible and will cancel your subscription, so proceed with caution.
|
|
||||||
</AlertDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="destructive">Delete Account</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader className="space-y-4">
|
|
||||||
<DialogTitle>Delete Account</DialogTitle>
|
|
||||||
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription className="selection:bg-red-100">
|
|
||||||
This action is not reversible. Please be certain.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{hasTwoFactorAuthentication && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription className="selection:bg-red-100">
|
|
||||||
Disable Two Factor Authentication before deleting your account.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
Documenso will delete <span className="font-semibold">all of your documents</span>
|
|
||||||
, along with all of your completed documents, signatures, and all other resources
|
|
||||||
belonging to your Account.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
onClick={onDeleteAccount}
|
|
||||||
loading={isDeletingAccount}
|
|
||||||
variant="destructive"
|
|
||||||
disabled={hasTwoFactorAuthentication}
|
|
||||||
>
|
|
||||||
{isDeletingAccount ? 'Deleting account...' : 'Delete Account'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -5,9 +5,6 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
|||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { ProfileForm } from '~/components/forms/profile';
|
import { ProfileForm } from '~/components/forms/profile';
|
||||||
|
|
||||||
import { ClaimProfileAlertDialog } from './claim-profile-alert-dialog';
|
|
||||||
import { DeleteAccountDialog } from './delete-account-dialog';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Profile',
|
title: 'Profile',
|
||||||
};
|
};
|
||||||
@@ -19,13 +16,7 @@ export default async function ProfileSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
||||||
|
|
||||||
<ProfileForm className="mb-8 max-w-xl" user={user} />
|
<ProfileForm user={user} className="max-w-xl" />
|
||||||
|
|
||||||
<ClaimProfileAlertDialog className="max-w-xl" user={user} />
|
|
||||||
|
|
||||||
<hr className="my-4 max-w-xl" />
|
|
||||||
|
|
||||||
<DeleteAccountDialog className="max-w-xl" user={user} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
|
||||||
|
|
||||||
import ActivityPageBackButton from '../../../../../components/(dashboard)/settings/layout/activity-back';
|
|
||||||
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
|
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -12,14 +9,11 @@ export const metadata: Metadata = {
|
|||||||
export default function SettingsSecurityActivityPage() {
|
export default function SettingsSecurityActivityPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<h3 className="text-2xl font-semibold">Security activity</h3>
|
||||||
title="Security activity"
|
|
||||||
subtitle="View all recent security activity related to your account."
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
>
|
View all recent security activity related to your account.
|
||||||
<div>
|
</p>
|
||||||
<ActivityPageBackButton />
|
|
||||||
</div>
|
|
||||||
</SettingsHeader>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -19,15 +18,7 @@ export default async function ApiTokensPage() {
|
|||||||
<h3 className="text-2xl font-semibold">API Tokens</h3>
|
<h3 className="text-2xl font-semibold">API Tokens</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
On this page, you can create new API tokens and manage the existing ones. <br />
|
On this page, you can create new API tokens and manage the existing ones.
|
||||||
You can view our swagger docs{' '}
|
|
||||||
<a
|
|
||||||
className="text-primary underline"
|
|
||||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/openapi`}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@@ -25,7 +24,7 @@ import { Switch } from '@documenso/ui/primitives/switch';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox';
|
import { MultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/multiselect-combobox';
|
||||||
|
|
||||||
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
||||||
|
|
||||||
@@ -33,7 +32,7 @@ type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
|
|||||||
|
|
||||||
export type WebhookPageOptions = {
|
export type WebhookPageOptions = {
|
||||||
params: {
|
params: {
|
||||||
id: string;
|
id: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
|
|
||||||
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
|
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
|
||||||
{
|
{
|
||||||
id: params.id,
|
id: Number(params.id),
|
||||||
},
|
},
|
||||||
{ enabled: !!params.id },
|
{ enabled: !!params.id },
|
||||||
);
|
);
|
||||||
@@ -63,7 +62,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
const onSubmit = async (data: TEditWebhookFormSchema) => {
|
const onSubmit = async (data: TEditWebhookFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await updateWebhook({
|
await updateWebhook({
|
||||||
id: params.id,
|
id: Number(params.id),
|
||||||
...data,
|
...data,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,86 +88,43 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
title="Edit webhook"
|
title="Edit webhook"
|
||||||
subtitle="On this page, you can edit the webhook and its settings."
|
subtitle="On this page, you can edit the webhook and its settings."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
||||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<fieldset
|
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||||
className="flex h-full max-w-xl flex-col gap-y-6"
|
<FormField
|
||||||
disabled={form.formState.isSubmitting}
|
control={form.control}
|
||||||
>
|
name="webhookUrl"
|
||||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
render={({ field }) => (
|
||||||
<FormField
|
<FormItem>
|
||||||
control={form.control}
|
<FormLabel htmlFor="webhookUrl">Webhook URL</FormLabel>
|
||||||
name="webhookUrl"
|
<Input {...field} id="webhookUrl" type="text" />
|
||||||
render={({ field }) => (
|
<FormMessage />
|
||||||
<FormItem className="flex-1">
|
</FormItem>
|
||||||
<FormLabel required>Webhook URL</FormLabel>
|
)}
|
||||||
<FormControl>
|
/>
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
The URL for Documenso to send webhook events to.
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Enabled</FormLabel>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
className="bg-background"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="eventTriggers"
|
name="eventTriggers"
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<FormItem className="flex flex-col gap-2">
|
<FormItem className="flex flex-col">
|
||||||
<FormLabel required>Triggers</FormLabel>
|
<FormLabel required>Event triggers</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TriggerMultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
listValues={value}
|
listValues={value}
|
||||||
onChange={(values: string[]) => {
|
onChange={(values: string[]) => {
|
||||||
onChange(values);
|
onChange(values);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
The events that will trigger a webhook to be sent to your URL.
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="secret"
|
name="secret"
|
||||||
@@ -178,16 +134,28 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
A secret that will be sent to your URL so you can verify that the request has
|
|
||||||
been sent by Documenso.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-x-2">
|
||||||
|
<FormLabel className="mt-2">Active</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
className="bg-background"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
Update webhook
|
Update webhook
|
||||||
|
|||||||
@@ -2,19 +2,16 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Zap } from 'lucide-react';
|
||||||
|
import { ToggleLeft, ToggleRight } from 'lucide-react';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog';
|
import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog';
|
||||||
import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog';
|
import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog';
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
export default function WebhookPage() {
|
export default function WebhookPage() {
|
||||||
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
|
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
|
||||||
@@ -46,52 +43,36 @@ export default function WebhookPage() {
|
|||||||
{webhooks && webhooks.length > 0 && (
|
{webhooks && webhooks.length > 0 && (
|
||||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
||||||
{webhooks?.map((webhook) => (
|
{webhooks?.map((webhook) => (
|
||||||
<div
|
<div key={webhook.id} className="border-border rounded-lg border p-4">
|
||||||
key={webhook.id}
|
<div className="flex items-center justify-between gap-x-4">
|
||||||
className={cn(
|
|
||||||
'border-border rounded-lg border p-4',
|
|
||||||
!webhook.enabled && 'bg-muted/40',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="truncate font-mono text-xs">{webhook.id}</div>
|
<h4 className="text-lg font-semibold">Webhook URL</h4>
|
||||||
|
<p className="text-muted-foreground">{webhook.webhookUrl}</p>
|
||||||
<div className="mt-1.5 flex items-center gap-4">
|
<h4 className="mt-4 text-lg font-semibold">Event triggers</h4>
|
||||||
<h5
|
{webhook.eventTriggers.map((trigger, index) => (
|
||||||
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
|
<span key={index} className="text-muted-foreground flex flex-row items-center">
|
||||||
title={webhook.webhookUrl}
|
<Zap className="mr-1 h-4 w-4" /> {trigger}
|
||||||
>
|
</span>
|
||||||
{webhook.webhookUrl}
|
))}
|
||||||
</h5>
|
{webhook.enabled ? (
|
||||||
|
<h4 className="mt-4 flex items-center gap-2 text-lg">
|
||||||
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
|
Active <ToggleRight className="h-6 w-6 fill-green-200 stroke-green-400" />
|
||||||
{webhook.enabled ? 'Enabled' : 'Disabled'}
|
</h4>
|
||||||
</Badge>
|
) : (
|
||||||
</div>
|
<h4 className="mt-4 flex items-center gap-2 text-lg">
|
||||||
|
Inactive <ToggleLeft className="h-6 w-6 fill-slate-200 stroke-slate-400" />
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
</h4>
|
||||||
Listening to{' '}
|
)}
|
||||||
{webhook.eventTriggers
|
|
||||||
.map((trigger) => toFriendlyWebhookEventName(trigger))
|
|
||||||
.join(', ')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
|
||||||
Created on{' '}
|
|
||||||
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
|
|
||||||
</Button>
|
|
||||||
<DeleteWebhookDialog webhook={webhook}>
|
|
||||||
<Button variant="destructive">Delete</Button>
|
|
||||||
</DeleteWebhookDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-6 flex flex-col-reverse space-y-2 space-y-reverse sm:mt-0 sm:flex-row sm:justify-end sm:space-x-2 sm:space-y-0">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
|
||||||
|
</Button>
|
||||||
|
<DeleteWebhookDialog webhook={webhook}>
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
</DeleteWebhookDialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTransition } from 'react';
|
import { useState, useTransition } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { AlertTriangle, Loader } from 'lucide-react';
|
import { AlertTriangle, Loader, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { Recipient, Template } from '@documenso/prisma/client';
|
import type { Template } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
import { TemplateType } from '~/components/formatter/template-type';
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||||
import { DataTableTitle } from './data-table-title';
|
import { DataTableTitle } from './data-table-title';
|
||||||
import { UseTemplateDialog } from './use-template-dialog';
|
|
||||||
|
|
||||||
type TemplateWithRecipient = Template & {
|
|
||||||
Recipient: Recipient[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type TemplatesDataTableProps = {
|
type TemplatesDataTableProps = {
|
||||||
templates: TemplateWithRecipient[];
|
templates: Template[];
|
||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@@ -48,6 +47,14 @@ export const TemplatesDataTable = ({
|
|||||||
|
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({});
|
||||||
|
|
||||||
|
const { mutateAsync: createDocumentFromTemplate } =
|
||||||
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
updateSearchParams({
|
updateSearchParams({
|
||||||
@@ -57,6 +64,28 @@ export const TemplatesDataTable = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onUseButtonClick = async (templateId: number) => {
|
||||||
|
try {
|
||||||
|
const { id } = await createDocumentFromTemplate({
|
||||||
|
templateId,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document created',
|
||||||
|
description: 'Your document has been created from the template successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`${documentRootPath}/${id}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while creating document from template.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{remaining.documents === 0 && (
|
{remaining.documents === 0 && (
|
||||||
@@ -92,13 +121,22 @@ export const TemplatesDataTable = ({
|
|||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
accessorKey: 'actions',
|
accessorKey: 'actions',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
const isRowLoading = loadingStates[row.original.id];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<UseTemplateDialog
|
<Button
|
||||||
templateId={row.original.id}
|
disabled={isRowLoading || remaining.documents === 0}
|
||||||
recipients={row.original.Recipient}
|
loading={isRowLoading}
|
||||||
documentRootPath={documentRootPath}
|
onClick={async () => {
|
||||||
/>
|
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
||||||
|
await onUseButtonClick(row.original.id);
|
||||||
|
setLoadingStates((prev) => ({ ...prev, [row.original.id]: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
|
||||||
|
Use Template
|
||||||
|
</Button>
|
||||||
|
|
||||||
<DataTableActionDropdown
|
<DataTableActionDropdown
|
||||||
row={row.original}
|
row={row.original}
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
|
||||||
import * as z from 'zod';
|
|
||||||
|
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
|
||||||
import { RecipientRole } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
|
||||||
recipients: z.array(
|
|
||||||
z.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
name: z.string(),
|
|
||||||
role: z.nativeEnum(RecipientRole),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
|
||||||
|
|
||||||
export type UseTemplateDialogProps = {
|
|
||||||
templateId: number;
|
|
||||||
recipients: Recipient[];
|
|
||||||
documentRootPath: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function UseTemplateDialog({
|
|
||||||
recipients,
|
|
||||||
documentRootPath,
|
|
||||||
templateId,
|
|
||||||
}: UseTemplateDialogProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<TAddRecipientsForNewDocumentSchema>({
|
|
||||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
|
||||||
defaultValues: {
|
|
||||||
recipients:
|
|
||||||
recipients.length > 0
|
|
||||||
? recipients.map((recipient) => ({
|
|
||||||
nativeId: recipient.id,
|
|
||||||
formId: String(recipient.id),
|
|
||||||
name: recipient.name,
|
|
||||||
email: recipient.email,
|
|
||||||
role: recipient.role,
|
|
||||||
}))
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
|
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
|
||||||
|
|
||||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
|
||||||
try {
|
|
||||||
const { id } = await createDocumentFromTemplate({
|
|
||||||
templateId,
|
|
||||||
teamId: team?.id,
|
|
||||||
recipients: data.recipients,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Document created',
|
|
||||||
description: 'Your document has been created from the template successfully.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push(`${documentRootPath}/${id}`);
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'An error occurred while creating document from template.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
|
|
||||||
|
|
||||||
const { fields: formRecipients } = useFieldArray({
|
|
||||||
control,
|
|
||||||
name: 'recipients',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="cursor-pointer">
|
|
||||||
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
|
||||||
Use Template
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Document Recipients</DialogTitle>
|
|
||||||
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
{formRecipients.map((recipient, index) => (
|
|
||||||
<div
|
|
||||||
key={recipient.id}
|
|
||||||
data-native-id={recipient.id}
|
|
||||||
className="flex flex-wrap items-end gap-x-4"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label htmlFor={`recipient-${recipient.id}-email`}>
|
|
||||||
Email
|
|
||||||
<span className="text-destructive ml-1 inline-block font-medium">*</span>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name={`recipients.${index}.email`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Input
|
|
||||||
id={`recipient-${recipient.id}-email`}
|
|
||||||
type="email"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name={`recipients.${index}.name`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Input
|
|
||||||
id={`recipient-${recipient.id}-name`}
|
|
||||||
type="text"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-[60px]">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name={`recipients.${index}.role`}
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<Select value={value} onValueChange={(x) => onChange(x)}>
|
|
||||||
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent className="" align="end">
|
|
||||||
<SelectItem value={RecipientRole.SIGNER}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
|
||||||
Signer
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.CC}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
|
||||||
Receives copy
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.APPROVER}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
|
||||||
Approver
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.VIEWER}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
|
||||||
Viewer
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full">
|
|
||||||
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
|
|
||||||
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="justify-end">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="secondary">
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
loading={isCreatingDocumentFromTemplate}
|
|
||||||
disabled={isCreatingDocumentFromTemplate}
|
|
||||||
onClick={onCreateDocumentFromTemplate}
|
|
||||||
>
|
|
||||||
Create Document
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,6 @@ import { NameField } from './name-field';
|
|||||||
import { NoLongerAvailable } from './no-longer-available';
|
import { NoLongerAvailable } from './no-longer-available';
|
||||||
import { SigningProvider } from './provider';
|
import { SigningProvider } from './provider';
|
||||||
import { SignatureField } from './signature-field';
|
import { SignatureField } from './signature-field';
|
||||||
import { TextField } from './text-field';
|
|
||||||
|
|
||||||
export type SigningPageProps = {
|
export type SigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@@ -169,9 +168,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
.with(FieldType.EMAIL, () => (
|
.with(FieldType.EMAIL, () => (
|
||||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
))
|
))
|
||||||
.with(FieldType.TEXT, () => (
|
|
||||||
<TextField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.otherwise(() => null),
|
.otherwise(() => null),
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
</ElementVisible>
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState, useTransition } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { SigningFieldContainer } from './signing-field-container';
|
|
||||||
|
|
||||||
export type TextFieldProps = {
|
|
||||||
field: FieldWithSignature;
|
|
||||||
recipient: Recipient;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
|
||||||
trpc.field.signFieldWithToken.useMutation();
|
|
||||||
|
|
||||||
const {
|
|
||||||
mutateAsync: removeSignedFieldWithToken,
|
|
||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
|
||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
|
||||||
|
|
||||||
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
|
||||||
const [localText, setLocalCustomText] = useState('');
|
|
||||||
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showCustomTextModal && !isLocalSignatureSet) {
|
|
||||||
setLocalCustomText('');
|
|
||||||
}
|
|
||||||
}, [showCustomTextModal, isLocalSignatureSet]);
|
|
||||||
|
|
||||||
const onSign = async () => {
|
|
||||||
try {
|
|
||||||
if (!localText) {
|
|
||||||
setIsLocalSignatureSet(false);
|
|
||||||
setShowCustomTextModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!localText) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await signFieldWithToken({
|
|
||||||
token: recipient.token,
|
|
||||||
fieldId: field.id,
|
|
||||||
value: localText,
|
|
||||||
isBase64: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
setLocalCustomText('');
|
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'An error occurred while signing the document.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRemove = async () => {
|
|
||||||
try {
|
|
||||||
await removeSignedFieldWithToken({
|
|
||||||
token: recipient.token,
|
|
||||||
fieldId: field.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'An error occurred while removing the text.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
|
||||||
{isLoading && (
|
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!field.inserted && (
|
|
||||||
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Text</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
|
|
||||||
|
|
||||||
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogTitle>
|
|
||||||
Enter your Text <span className="text-muted-foreground">({recipient.email})</span>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<div className="">
|
|
||||||
<Label htmlFor="custom-text">Custom Text</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="custom-text"
|
|
||||||
className="border-border mt-2 w-full rounded-md border"
|
|
||||||
onChange={(e) => setLocalCustomText(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setShowCustomTextModal(false);
|
|
||||||
setLocalCustomText('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={!localText}
|
|
||||||
onClick={() => {
|
|
||||||
setShowCustomTextModal(false);
|
|
||||||
setIsLocalSignatureSet(true);
|
|
||||||
void onSign();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save Text
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</SigningFieldContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
|
||||||
|
|
||||||
import { DocumentEditPageView } from '~/app/(dashboard)/documents/[id]/edit/document-edit-page-view';
|
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
teamUrl: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TeamsDocumentEditPage({ params }: DocumentPageProps) {
|
|
||||||
const { teamUrl } = params;
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
|
||||||
|
|
||||||
return <DocumentEditPageView params={params} team={team} />;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
|
||||||
|
|
||||||
import { DocumentLogsPageView } from '~/app/(dashboard)/documents/[id]/logs/document-logs-page-view';
|
|
||||||
|
|
||||||
export type TeamDocumentsLogsPageProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
teamUrl: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TeamsDocumentsLogsPage({ params }: TeamDocumentsLogsPageProps) {
|
|
||||||
const { teamUrl } = params;
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
|
||||||
|
|
||||||
return <DocumentLogsPageView params={params} team={team} />;
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import { SubscriptionStatus } from '@documenso/prisma/client';
|
|||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
import { TeamProvider } from '~/providers/team';
|
|
||||||
|
|
||||||
import { LayoutBillingBanner } from './layout-billing-banner';
|
import { LayoutBillingBanner } from './layout-billing-banner';
|
||||||
|
|
||||||
@@ -57,9 +56,7 @@ export default async function AuthenticatedTeamsLayout({
|
|||||||
|
|
||||||
<Header user={user} teams={teams} />
|
<Header user={user} teams={teams} />
|
||||||
|
|
||||||
<TeamProvider team={team}>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
|
||||||
</TeamProvider>
|
|
||||||
|
|
||||||
<RefreshOnFocus />
|
<RefreshOnFocus />
|
||||||
</LimitsProvider>
|
</LimitsProvider>
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
|
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
import { ApiTokenForm } from '~/components/forms/token';
|
|
||||||
|
|
||||||
type ApiTokensPageProps = {
|
|
||||||
params: {
|
|
||||||
teamUrl: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
|
|
||||||
const { teamUrl } = params;
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
|
||||||
|
|
||||||
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold">API Tokens</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
On this page, you can create new API tokens and manage the existing ones. <br />
|
|
||||||
You can view our swagger docs{' '}
|
|
||||||
<a
|
|
||||||
className="text-primary underline"
|
|
||||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/openapi`}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<ApiTokenForm className="max-w-xl" teamId={team.id} />
|
|
||||||
|
|
||||||
<hr className="mb-4 mt-8" />
|
|
||||||
|
|
||||||
<h4 className="text-xl font-medium">Your existing tokens</h4>
|
|
||||||
|
|
||||||
{tokens.length === 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
|
||||||
Your tokens will be shown here once you create them.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tokens.length > 0 && (
|
|
||||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
|
||||||
{tokens.map((token) => (
|
|
||||||
<div key={token.id} className="border-border rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-between gap-x-4">
|
|
||||||
<div>
|
|
||||||
<h5 className="text-base">{token.name}</h5>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
|
||||||
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
|
|
||||||
</p>
|
|
||||||
{token.expires ? (
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
|
||||||
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
|
||||||
Token doesn't have an expiration date
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<DeleteTokenDialog token={token} teamId={team.id}>
|
|
||||||
<Button variant="destructive">Delete</Button>
|
|
||||||
</DeleteTokenDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
|
||||||
import { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
|
||||||
|
|
||||||
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
|
|
||||||
|
|
||||||
export type WebhookPageOptions = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function WebhookPage({ params }: WebhookPageOptions) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
|
|
||||||
{
|
|
||||||
id: params.id,
|
|
||||||
teamId: team.id,
|
|
||||||
},
|
|
||||||
{ enabled: !!params.id && !!team.id },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TEditWebhookFormSchema>({
|
|
||||||
resolver: zodResolver(ZEditWebhookFormSchema),
|
|
||||||
values: {
|
|
||||||
webhookUrl: webhook?.webhookUrl ?? '',
|
|
||||||
eventTriggers: webhook?.eventTriggers ?? [],
|
|
||||||
secret: webhook?.secret ?? '',
|
|
||||||
enabled: webhook?.enabled ?? true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: TEditWebhookFormSchema) => {
|
|
||||||
try {
|
|
||||||
await updateWebhook({
|
|
||||||
id: params.id,
|
|
||||||
teamId: team.id,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Webhook updated',
|
|
||||||
description: 'The webhook has been updated successfully.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to update webhook',
|
|
||||||
description: 'We encountered an error while updating the webhook. Please try again later.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader
|
|
||||||
title="Edit webhook"
|
|
||||||
subtitle="On this page, you can edit the webhook and its settings."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full max-w-xl flex-col gap-y-6"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="webhookUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel required>Webhook URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
The URL for Documenso to send webhook events to.
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Enabled</FormLabel>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
className="bg-background"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="eventTriggers"
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<FormItem className="flex flex-col gap-2">
|
|
||||||
<FormLabel required>Triggers</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<TriggerMultiSelectCombobox
|
|
||||||
listValues={value}
|
|
||||||
onChange={(values: string[]) => {
|
|
||||||
onChange(values);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
The events that will trigger a webhook to be sent to your URL.
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="secret"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Secret</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
A secret that will be sent to your URL so you can verify that the request has
|
|
||||||
been sent by Documenso.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
Update webhook
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
|
||||||
import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog';
|
|
||||||
import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog';
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export default function WebhookPage() {
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const { data: webhooks, isLoading } = trpc.webhook.getTeamWebhooks.useQuery({
|
|
||||||
teamId: team.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader
|
|
||||||
title="Webhooks"
|
|
||||||
subtitle="On this page, you can create new Webhooks and manage the existing ones."
|
|
||||||
>
|
|
||||||
<CreateWebhookDialog />
|
|
||||||
</SettingsHeader>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{webhooks && webhooks.length === 0 && (
|
|
||||||
// TODO: Perhaps add some illustrations here to make the page more engaging
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
|
||||||
You have no webhooks yet. Your webhooks will be shown here once you create them.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{webhooks && webhooks.length > 0 && (
|
|
||||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
|
||||||
{webhooks?.map((webhook) => (
|
|
||||||
<div
|
|
||||||
key={webhook.id}
|
|
||||||
className={cn(
|
|
||||||
'border-border rounded-lg border p-4',
|
|
||||||
!webhook.enabled && 'bg-muted/40',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="truncate font-mono text-xs">{webhook.id}</div>
|
|
||||||
|
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
|
||||||
<h5
|
|
||||||
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
|
|
||||||
title={webhook.webhookUrl}
|
|
||||||
>
|
|
||||||
{webhook.webhookUrl}
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
|
|
||||||
{webhook.enabled ? 'Enabled' : 'Disabled'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
|
||||||
Listening to{' '}
|
|
||||||
{webhook.eventTriggers
|
|
||||||
.map((trigger) => toFriendlyWebhookEventName(trigger))
|
|
||||||
.join(', ')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
|
||||||
Created on{' '}
|
|
||||||
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/t/${team.url}/settings/webhooks/${webhook.id}`}>Edit</Link>
|
|
||||||
</Button>
|
|
||||||
<DeleteWebhookDialog webhook={webhook}>
|
|
||||||
<Button variant="destructive">Delete</Button>
|
|
||||||
</DeleteWebhookDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,19 +9,17 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div>
|
||||||
<div className="w-full">
|
<h1 className="text-4xl font-semibold">Email sent!</h1>
|
||||||
<h1 className="text-4xl font-semibold">Email sent!</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
A password reset email has been sent, if you have an account you should see it in your
|
A password reset email has been sent, if you have an account you should see it in your inbox
|
||||||
inbox shortly.
|
shortly.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/signin">Return to sign in</Link>
|
<Link href="/signin">Return to sign in</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,24 +9,22 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div>
|
||||||
<div className="w-full">
|
<h1 className="text-4xl font-semibold">Forgot your password?</h1>
|
||||||
<h1 className="text-3xl font-semibold">Forgot your password?</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
No worries, it happens! Enter your email and we'll email you a special link to reset your
|
No worries, it happens! Enter your email and we'll email you a special link to reset your
|
||||||
password.
|
password.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ForgotPasswordForm className="mt-4" />
|
<ForgotPasswordForm className="mt-4" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Remembered your password?{' '}
|
Remembered your password?{' '}
|
||||||
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||||
Sign In
|
Sign In
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ type UnauthenticatedLayoutProps = {
|
|||||||
|
|
||||||
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
|
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||||
<div>
|
<div className="relative flex w-full max-w-md items-center gap-x-24">
|
||||||
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
||||||
<Image
|
<Image
|
||||||
src={backgroundPattern}
|
src={backgroundPattern}
|
||||||
alt="background pattern"
|
alt="background pattern"
|
||||||
@@ -20,7 +20,7 @@ export default function UnauthenticatedLayout({ children }: UnauthenticatedLayou
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full">{children}</div>
|
<div className="w-full">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,21 +19,19 @@ export default async function ResetPasswordPage({ params: { token } }: ResetPass
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div className="w-full">
|
||||||
<div className="w-full">
|
<h1 className="text-4xl font-semibold">Reset Password</h1>
|
||||||
<h1 className="text-4xl font-semibold">Reset Password</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Please choose your new password </p>
|
<p className="text-muted-foreground mt-2 text-sm">Please choose your new password </p>
|
||||||
|
|
||||||
<ResetPasswordForm token={token} className="mt-4" />
|
<ResetPasswordForm token={token} className="mt-4" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,19 +9,17 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div>
|
||||||
<div className="w-full">
|
<h1 className="text-4xl font-semibold">Unable to reset password</h1>
|
||||||
<h1 className="text-3xl font-semibold">Unable to reset password</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
The token you have used to reset your password is either expired or it never existed. If
|
The token you have used to reset your password is either expired or it never existed. If you
|
||||||
you have still forgotten your password, please request a new reset link.
|
have still forgotten your password, please request a new reset link.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button className="mt-4" asChild>
|
<Button className="mt-4" asChild>
|
||||||
<Link href="/signin">Return to sign in</Link>
|
<Link href="/signin">Return to sign in</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,27 +30,36 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div>
|
||||||
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
|
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||||
<h1 className="text-2xl font-semibold">Sign in to your account</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||||
Welcome back, we are lucky to have you.
|
Welcome back, we are lucky to have you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SignInForm
|
||||||
|
className="mt-4"
|
||||||
|
initialEmail={email || undefined}
|
||||||
|
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||||
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<hr className="-mx-6 my-4" />
|
<p className="mt-2.5 text-center">
|
||||||
|
<Link
|
||||||
<SignInForm initialEmail={email || undefined} isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
href="/forgot-password"
|
||||||
|
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
||||||
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
>
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
Forgot your password?
|
||||||
Don't have an account?{' '}
|
</Link>
|
||||||
<Link href="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
|
</p>
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { env } from 'next-runtime-env';
|
import { env } from 'next-runtime-env';
|
||||||
@@ -6,7 +7,7 @@ import { env } from 'next-runtime-env';
|
|||||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
|
|
||||||
import { SignUpFormV2 } from '~/components/forms/v2/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Sign Up',
|
title: 'Sign Up',
|
||||||
@@ -33,10 +34,26 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SignUpFormV2
|
<div>
|
||||||
className="w-screen max-w-screen-2xl px-4 md:px-16"
|
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||||
initialEmail={email || undefined}
|
|
||||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||||
/>
|
Create your account and start using state-of-the-art document signing. Open and beautiful
|
||||||
|
signing is within your grasp.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SignUpForm
|
||||||
|
className="mt-4"
|
||||||
|
initialEmail={email || undefined}
|
||||||
|
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||||
|
Sign in instead
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,18 +29,16 @@ export default async function AcceptInvitationPage({
|
|||||||
|
|
||||||
if (!teamMemberInvite) {
|
if (!teamMemberInvite) {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div>
|
||||||
<div className="w-full">
|
<h1 className="text-4xl font-semibold">Invalid token</h1>
|
||||||
<h1 className="text-4xl font-semibold">Invalid token</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
This token is invalid or has expired. Please contact your team for a new invitation.
|
This token is invalid or has expired. Please contact your team for a new invitation.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/">Return</Link>
|
<Link href="/">Return</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,18 +22,16 @@ export default async function VerifyTeamEmailPage({ params: { token } }: VerifyT
|
|||||||
|
|
||||||
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
|
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div>
|
||||||
<div className="w-full">
|
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
This link is invalid or has expired. Please contact your team to resend a verification.
|
This link is invalid or has expired. Please contact your team to resend a verification.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/">Return</Link>
|
<Link href="/">Return</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,19 +25,17 @@ export default async function VerifyTeamTransferPage({
|
|||||||
|
|
||||||
if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
|
if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div>
|
||||||
<div className="w-full">
|
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
This link is invalid or has expired. Please contact your team to resend a transfer
|
This link is invalid or has expired. Please contact your team to resend a transfer
|
||||||
request.
|
request.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/">Return</Link>
|
<Link href="/">Return</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,25 +4,23 @@ import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-
|
|||||||
|
|
||||||
export default function UnverifiedAccount() {
|
export default function UnverifiedAccount() {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div className="flex w-full items-start">
|
||||||
<div className="flex items-start">
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
<div className="mr-4 mt-1 hidden md:block">
|
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
|
||||||
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
|
</div>
|
||||||
</div>
|
<div className="">
|
||||||
<div className="">
|
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
|
||||||
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">
|
<p className="text-muted-foreground mt-4">
|
||||||
To gain access to your account, please confirm your email address by clicking on the
|
To gain access to your account, please confirm your email address by clicking on the
|
||||||
confirmation link from your inbox.
|
confirmation link from your inbox.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">
|
<p className="text-muted-foreground mt-4">
|
||||||
If you don't find the confirmation link in your inbox, you can request a new one below.
|
If you don't find the confirmation link in your inbox, you can request a new one below.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SendConfirmationEmailForm />
|
<SendConfirmationEmailForm />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,17 +14,15 @@ export type PageProps = {
|
|||||||
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
|
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div className="w-full">
|
||||||
<div className="w-full">
|
<div className="mb-4 text-red-300">
|
||||||
<div className="mb-4 text-red-300">
|
<XOctagon />
|
||||||
<XOctagon />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-4xl font-semibold">No token provided</h2>
|
|
||||||
<p className="text-muted-foreground mt-2 text-base">
|
|
||||||
It seems that there is no token provided. Please check your email and try again.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-4xl font-semibold">No token provided</h2>
|
||||||
|
<p className="text-muted-foreground mt-2 text-base">
|
||||||
|
It seems that there is no token provided. Please check your email and try again.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,66 +31,17 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
|
|||||||
|
|
||||||
if (verified === null) {
|
if (verified === null) {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
|
||||||
<div className="flex w-full items-start">
|
|
||||||
<div className="mr-4 mt-1 hidden md:block">
|
|
||||||
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">
|
|
||||||
We were unable to verify your email. If your email is not verified already, please try
|
|
||||||
again.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button className="mt-4" asChild>
|
|
||||||
<Link href="/">Go back home</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verified) {
|
|
||||||
return (
|
|
||||||
<div className="w-screen max-w-lg px-4">
|
|
||||||
<div className="flex w-full items-start">
|
|
||||||
<div className="mr-4 mt-1 hidden md:block">
|
|
||||||
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">
|
|
||||||
It seems that the provided token has expired. We've just sent you another token,
|
|
||||||
please check your email and try again.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button className="mt-4" asChild>
|
|
||||||
<Link href="/">Go back home</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-screen max-w-lg px-4">
|
|
||||||
<div className="flex w-full items-start">
|
<div className="flex w-full items-start">
|
||||||
<div className="mr-4 mt-1 hidden md:block">
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
|
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
|
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">
|
<p className="text-muted-foreground mt-4">
|
||||||
Your email has been successfully confirmed! You can now use all features of Documenso.
|
We were unable to verify your email. If your email is not verified already, please try
|
||||||
|
again.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button className="mt-4" asChild>
|
<Button className="mt-4" asChild>
|
||||||
@@ -100,6 +49,49 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
|
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
It seems that the provided token has expired. We've just sent you another token, please
|
||||||
|
check your email and try again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/">Go back home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
|
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
Your email has been successfully confirmed! You can now use all features of Documenso.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/">Go back home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,26 +11,22 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function EmailVerificationWithoutTokenPage() {
|
export default function EmailVerificationWithoutTokenPage() {
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div className="flex w-full items-start">
|
||||||
<div className="flex w-full items-start">
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
<div className="mr-4 mt-1 hidden md:block">
|
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||||
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold md:text-4xl">
|
<h2 className="text-2xl font-bold md:text-4xl">Uh oh! Looks like you're missing a token</h2>
|
||||||
Uh oh! Looks like you're missing a token
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">
|
<p className="text-muted-foreground mt-4">
|
||||||
It seems that there is no token provided, if you are trying to verify your email please
|
It seems that there is no token provided, if you are trying to verify your email please
|
||||||
follow the link in your email.
|
follow the link in your email.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button className="mt-4" asChild>
|
<Button className="mt-4" asChild>
|
||||||
<Link href="/">Go back home</Link>
|
<Link href="/">Go back home</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
|
||||||
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
|
||||||
|
|
||||||
export const Banner = async () => {
|
|
||||||
const banner = await getSiteSettings().then((settings) =>
|
|
||||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{banner && banner.enabled && (
|
|
||||||
<div className="mb-2" style={{ background: banner.data.bgColor }}>
|
|
||||||
<div
|
|
||||||
className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"
|
|
||||||
style={{ color: banner.data.textColor }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span dangerouslySetInnerHTML={{ __html: banner.data.content }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Banner
|
|
||||||
// Custom Text
|
|
||||||
// Custom Text with Custom Icon
|
|
||||||
@@ -166,24 +166,22 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
{teams.map((team) => (
|
||||||
{teams.map((team) => (
|
<DropdownMenuItem asChild key={team.id}>
|
||||||
<DropdownMenuItem asChild key={team.id}>
|
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
||||||
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
<AvatarWithText
|
||||||
<AvatarWithText
|
avatarFallback={formatAvatarFallback(team.name)}
|
||||||
avatarFallback={formatAvatarFallback(team.name)}
|
primaryText={team.name}
|
||||||
primaryText={team.name}
|
secondaryText={formatSecondaryAvatarText(team)}
|
||||||
secondaryText={formatSecondaryAvatarText(team)}
|
rightSideComponent={
|
||||||
rightSideComponent={
|
isPathTeamUrl(team.url) && (
|
||||||
isPathTeamUrl(team.url) && (
|
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
)
|
||||||
)
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</Link>
|
||||||
</Link>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
export default function ActivityPageBackButton() {
|
|
||||||
const router = useRouter();
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
className="flex-shrink-0"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
void router.back();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -51,6 +51,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Link href="/settings/webhooks">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Webhook className="mr-2 h-5 w-5" />
|
||||||
|
Webhooks
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -77,19 +90,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/webhooks">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Webhook className="mr-2 h-5 w-5" />
|
|
||||||
Webhooks
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type SettingsHeaderProps = {
|
export type SettingsHeaderProps = {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
|
export const SettingsHeader = ({ children, title, subtitle }: SettingsHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn('flex flex-row items-center justify-between', className)}>
|
<div className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">{title}</h3>
|
<h3 className="text-lg font-medium">{title}</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Link href="/settings/webhooks">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Webhook className="mr-2 h-5 w-5" />
|
||||||
|
Webhooks
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -80,19 +93,6 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/webhooks">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Webhook className="mr-2 h-5 w-5" />
|
|
||||||
Webhooks
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -32,18 +32,12 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DeleteTokenDialogProps = {
|
export type DeleteTokenDialogProps = {
|
||||||
teamId?: number;
|
|
||||||
token: Pick<ApiToken, 'id' | 'name'>;
|
token: Pick<ApiToken, 'id' | 'name'>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DeleteTokenDialog({
|
export default function DeleteTokenDialog({ token, onDelete, children }: DeleteTokenDialogProps) {
|
||||||
teamId,
|
|
||||||
token,
|
|
||||||
onDelete,
|
|
||||||
children,
|
|
||||||
}: DeleteTokenDialogProps) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -76,7 +70,6 @@ export default function DeleteTokenDialog({
|
|||||||
try {
|
try {
|
||||||
await deleteTokenMutation({
|
await deleteTokenMutation({
|
||||||
id: token.id,
|
id: token.id,
|
||||||
teamId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
|
import { ZCreateWebhookFormSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@@ -35,11 +34,7 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
import { Switch } from '@documenso/ui/primitives/switch';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { MultiSelectCombobox } from './multiselect-combobox';
|
||||||
|
|
||||||
import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox';
|
|
||||||
|
|
||||||
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
|
|
||||||
|
|
||||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||||
|
|
||||||
@@ -50,9 +45,6 @@ export type CreateWebhookDialogProps = {
|
|||||||
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
|
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm<TCreateWebhookFormSchema>({
|
const form = useForm<TCreateWebhookFormSchema>({
|
||||||
@@ -67,20 +59,9 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
|
|||||||
|
|
||||||
const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation();
|
const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation();
|
||||||
|
|
||||||
const onSubmit = async ({
|
const onSubmit = async (values: TCreateWebhookFormSchema) => {
|
||||||
enabled,
|
|
||||||
eventTriggers,
|
|
||||||
secret,
|
|
||||||
webhookUrl,
|
|
||||||
}: TCreateWebhookFormSchema) => {
|
|
||||||
try {
|
try {
|
||||||
await createWebhook({
|
await createWebhook(values);
|
||||||
enabled,
|
|
||||||
eventTriggers,
|
|
||||||
secret,
|
|
||||||
webhookUrl,
|
|
||||||
teamId: team?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
@@ -111,7 +92,7 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
|
|||||||
{trigger ?? <Button className="flex-shrink-0">Create Webhook</Button>}
|
{trigger ?? <Button className="flex-shrink-0">Create Webhook</Button>}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent className="max-w-lg" position="center">
|
<DialogContent position="center">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create webhook</DialogTitle>
|
<DialogTitle>Create webhook</DialogTitle>
|
||||||
<DialogDescription>On this page, you can create a new webhook.</DialogDescription>
|
<DialogDescription>On this page, you can create a new webhook.</DialogDescription>
|
||||||
@@ -123,68 +104,34 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
|
|||||||
className="flex h-full flex-col space-y-4"
|
className="flex h-full flex-col space-y-4"
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="webhookUrl"
|
||||||
name="webhookUrl"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem className="flex-1">
|
<FormLabel required>Webhook URL</FormLabel>
|
||||||
<FormLabel required>Webhook URL</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<Input className="bg-background" {...field} />
|
||||||
<Input className="bg-background" {...field} />
|
</FormControl>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
<FormDescription>
|
)}
|
||||||
The URL for Documenso to send webhook events to.
|
/>
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Enabled</FormLabel>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
className="bg-background"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="eventTriggers"
|
name="eventTriggers"
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<FormItem className="flex flex-col gap-2">
|
<FormItem className="flex flex-col gap-2">
|
||||||
<FormLabel required>Triggers</FormLabel>
|
<FormLabel required>Event triggers</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TriggerMultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
listValues={value}
|
listValues={value}
|
||||||
onChange={(values: string[]) => {
|
onChange={(values: string[]) => {
|
||||||
onChange(values);
|
onChange(values);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
The events that will trigger a webhook to be sent to your URL.
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -203,11 +150,24 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
|
|||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormDescription>
|
<FormField
|
||||||
A secret that will be sent to your URL so you can verify that the request has
|
control={form.control}
|
||||||
been sent by Documenso.
|
name="enabled"
|
||||||
</FormDescription>
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2">
|
||||||
|
<FormLabel className="mt-2">Active</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
className="bg-background"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type DeleteWebhookDialogProps = {
|
export type DeleteWebhookDialogProps = {
|
||||||
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
@@ -42,9 +40,6 @@ export type DeleteWebhookDialogProps = {
|
|||||||
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
|
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const deleteMessage = `delete ${webhook.webhookUrl}`;
|
const deleteMessage = `delete ${webhook.webhookUrl}`;
|
||||||
@@ -68,7 +63,7 @@ export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogPr
|
|||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteWebhook({ id: webhook.id, teamId: team?.id });
|
await deleteWebhook({ id: webhook.id });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Webhook deleted',
|
title: 'Webhook deleted',
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { WebhookTriggerEvents } from '@prisma/client/';
|
import { WebhookTriggerEvents } from '@prisma/client/';
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
|
||||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -17,21 +16,18 @@ import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitive
|
|||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
type TriggerMultiSelectComboboxProps = {
|
type ComboboxProps = {
|
||||||
listValues: string[];
|
listValues: string[];
|
||||||
onChange: (_values: string[]) => void;
|
onChange: (_values: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TriggerMultiSelectCombobox = ({
|
const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||||
listValues,
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
onChange,
|
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
||||||
}: TriggerMultiSelectComboboxProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const triggerEvents = Object.values(WebhookTriggerEvents);
|
const triggerEvents = Object.values(WebhookTriggerEvents);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedValues(listValues);
|
setSelectedValues(listValues);
|
||||||
}, [listValues]);
|
}, [listValues]);
|
||||||
|
|
||||||
@@ -39,7 +35,6 @@ export const TriggerMultiSelectCombobox = ({
|
|||||||
|
|
||||||
const handleSelect = (currentValue: string) => {
|
const handleSelect = (currentValue: string) => {
|
||||||
let newSelectedValues;
|
let newSelectedValues;
|
||||||
|
|
||||||
if (selectedValues.includes(currentValue)) {
|
if (selectedValues.includes(currentValue)) {
|
||||||
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
|
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
|
||||||
} else {
|
} else {
|
||||||
@@ -64,14 +59,9 @@ export const TriggerMultiSelectCombobox = ({
|
|||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="z-9999 w-full max-w-[280px] p-0">
|
<PopoverContent className="z-9999 w-[200px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput placeholder={truncateTitle(selectedValues.join(', '), 15)} />
|
||||||
placeholder={truncateTitle(
|
|
||||||
selectedValues.map((v) => toFriendlyWebhookEventName(v)).join(', '),
|
|
||||||
15,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<CommandEmpty>No value found.</CommandEmpty>
|
<CommandEmpty>No value found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{allEvents.map((value: string, i: number) => (
|
{allEvents.map((value: string, i: number) => (
|
||||||
@@ -82,7 +72,7 @@ export const TriggerMultiSelectCombobox = ({
|
|||||||
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
|
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{toFriendlyWebhookEventName(value)}
|
{value}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
@@ -91,3 +81,5 @@ export const TriggerMultiSelectCombobox = ({
|
|||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { MultiSelectCombobox };
|
||||||
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, usePathname } from 'next/navigation';
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Braces, CreditCard, Settings, Users, Webhook } from 'lucide-react';
|
import { CreditCard, Settings, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -21,8 +21,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
|
|
||||||
const settingsPath = `/t/${teamUrl}/settings`;
|
const settingsPath = `/t/${teamUrl}/settings`;
|
||||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
|
||||||
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
|
||||||
const billingPath = `/t/${teamUrl}/settings/billing`;
|
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,29 +48,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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() && (
|
{IS_BILLING_ENABLED() && (
|
||||||
<Link href={billingPath}>
|
<Link href={billingPath}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, usePathname } from 'next/navigation';
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Braces, CreditCard, Key, User, Webhook } from 'lucide-react';
|
import { CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -21,8 +21,6 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
|
|
||||||
const settingsPath = `/t/${teamUrl}/settings`;
|
const settingsPath = `/t/${teamUrl}/settings`;
|
||||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
|
||||||
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
|
||||||
const billingPath = `/t/${teamUrl}/settings/billing`;
|
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -58,29 +56,6 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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() && (
|
{IS_BILLING_ENABLED() && (
|
||||||
<Link href={billingPath}>
|
<Link href={billingPath}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
|
|
||||||
export type DocumentHistorySheetChangesProps = {
|
|
||||||
values: {
|
|
||||||
key: string | React.ReactNode;
|
|
||||||
value: string | React.ReactNode;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentHistorySheetChanges = ({ values }: DocumentHistorySheetChangesProps) => {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className="text-muted-foreground mt-3 block w-full space-y-0.5 text-xs"
|
|
||||||
variant="neutral"
|
|
||||||
>
|
|
||||||
{values.map(({ key, value }, i) => (
|
|
||||||
<p key={typeof key === 'string' ? key : i}>
|
|
||||||
<span>{key}: </span>
|
|
||||||
<span className="font-normal">{value}</span>
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { ArrowRightIcon, Loader } from 'lucide-react';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { UAParser } from 'ua-parser-js';
|
|
||||||
|
|
||||||
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
|
||||||
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
import { DocumentHistorySheetChanges } from './document-history-sheet-changes';
|
|
||||||
|
|
||||||
export type DocumentHistorySheetProps = {
|
|
||||||
documentId: number;
|
|
||||||
userId: number;
|
|
||||||
isMenuOpen?: boolean;
|
|
||||||
onMenuOpenChange?: (_value: boolean) => void;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentHistorySheet = ({
|
|
||||||
documentId,
|
|
||||||
userId,
|
|
||||||
isMenuOpen,
|
|
||||||
onMenuOpenChange,
|
|
||||||
children,
|
|
||||||
}: DocumentHistorySheetProps) => {
|
|
||||||
const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
isLoadingError,
|
|
||||||
refetch,
|
|
||||||
hasNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
|
|
||||||
{
|
|
||||||
documentId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
||||||
keepPreviousData: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
|
|
||||||
|
|
||||||
const extractBrowser = (userAgent?: string | null) => {
|
|
||||||
if (!userAgent) {
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
const parser = new UAParser(userAgent);
|
|
||||||
|
|
||||||
parser.setUA(userAgent);
|
|
||||||
|
|
||||||
const result = parser.getResult();
|
|
||||||
|
|
||||||
return result.browser.name;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the following formatting for a given text:
|
|
||||||
* - Uppercase first lower, lowercase rest
|
|
||||||
* - Replace _ with spaces
|
|
||||||
*
|
|
||||||
* @param text The text to format
|
|
||||||
* @returns The formatted text
|
|
||||||
*/
|
|
||||||
const formatGenericText = (text: string) => {
|
|
||||||
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
|
||||||
{children && <SheetTrigger asChild>{children}</SheetTrigger>}
|
|
||||||
|
|
||||||
<SheetContent
|
|
||||||
sheetClass="backdrop-blur-none"
|
|
||||||
className="flex w-full max-w-[500px] flex-col overflow-y-auto p-0"
|
|
||||||
>
|
|
||||||
<div className="text-foreground px-6 pt-6">
|
|
||||||
<h1 className="text-lg font-medium">Document history</h1>
|
|
||||||
<button
|
|
||||||
className="text-muted-foreground text-sm"
|
|
||||||
onClick={() => setIsUserDetailsVisible(!isUserDetailsVisible)}
|
|
||||||
>
|
|
||||||
{isUserDetailsVisible ? 'Hide' : 'Show'} additional information
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoadingError && (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center">
|
|
||||||
<p className="text-foreground/80 text-sm">Unable to load document history</p>
|
|
||||||
<button
|
|
||||||
onClick={async () => refetch()}
|
|
||||||
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
|
||||||
>
|
|
||||||
Click here to retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && (
|
|
||||||
<ul
|
|
||||||
className={cn('divide-y border-t', {
|
|
||||||
'mb-4 border-b': !hasNextPage,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{documentAuditLogs.map((auditLog) => (
|
|
||||||
<li className="px-4 py-2.5" key={auditLog.id}>
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<Avatar className="mr-2 h-9 w-9">
|
|
||||||
<AvatarFallback className="text-xs text-gray-400">
|
|
||||||
{(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-foreground text-xs font-bold">
|
|
||||||
{formatDocumentAuditLogActionString(auditLog, userId)}
|
|
||||||
</p>
|
|
||||||
<p className="text-foreground/50 text-xs">
|
|
||||||
<LocaleDate date={auditLog.createdAt} format="d MMM, yyyy HH:MM a" />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{match(auditLog)
|
|
||||||
.with(
|
|
||||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED },
|
|
||||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED },
|
|
||||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED },
|
|
||||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
|
|
||||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
|
|
||||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
|
|
||||||
() => null,
|
|
||||||
)
|
|
||||||
.with(
|
|
||||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED },
|
|
||||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED },
|
|
||||||
({ data }) => {
|
|
||||||
const values = [
|
|
||||||
{
|
|
||||||
key: 'Email',
|
|
||||||
value: data.recipientEmail,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Role',
|
|
||||||
value: formatGenericText(data.recipientRole),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Insert the name to the start of the array if available.
|
|
||||||
if (data.recipientName) {
|
|
||||||
values.unshift({
|
|
||||||
key: 'Name',
|
|
||||||
value: data.recipientName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DocumentHistorySheetChanges values={values} />;
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => {
|
|
||||||
if (data.changes.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentHistorySheetChanges
|
|
||||||
values={data.changes.map(({ type, from, to }) => ({
|
|
||||||
key: formatGenericText(type),
|
|
||||||
value: (
|
|
||||||
<span className="inline-flex flex-row items-center">
|
|
||||||
<span>{type === 'ROLE' ? formatGenericText(from) : from}</span>
|
|
||||||
<ArrowRightIcon className="h-4 w-4" />
|
|
||||||
<span>{type === 'ROLE' ? formatGenericText(to) : to}</span>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.with(
|
|
||||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED },
|
|
||||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED },
|
|
||||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED },
|
|
||||||
({ data }) => (
|
|
||||||
<DocumentHistorySheetChanges
|
|
||||||
values={[
|
|
||||||
{
|
|
||||||
key: 'Field',
|
|
||||||
value: formatGenericText(data.fieldType),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Recipient',
|
|
||||||
value: formatGenericText(data.fieldRecipientEmail),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
|
|
||||||
if (data.changes.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentHistorySheetChanges
|
|
||||||
values={data.changes.map((change) => ({
|
|
||||||
key: formatGenericText(change.type),
|
|
||||||
value: change.type === 'PASSWORD' ? '*********' : change.to,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => (
|
|
||||||
<DocumentHistorySheetChanges
|
|
||||||
values={[
|
|
||||||
{
|
|
||||||
key: 'Old',
|
|
||||||
value: data.from,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'New',
|
|
||||||
value: data.to,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => (
|
|
||||||
<DocumentHistorySheetChanges
|
|
||||||
values={[
|
|
||||||
{
|
|
||||||
key: 'Field inserted',
|
|
||||||
value: formatGenericText(data.field.type),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => (
|
|
||||||
<DocumentHistorySheetChanges
|
|
||||||
values={[
|
|
||||||
{
|
|
||||||
key: 'Field uninserted',
|
|
||||||
value: formatGenericText(data.field),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => (
|
|
||||||
<DocumentHistorySheetChanges
|
|
||||||
values={[
|
|
||||||
{
|
|
||||||
key: 'Type',
|
|
||||||
value: DOCUMENT_AUDIT_LOG_EMAIL_FORMAT[data.emailType].description,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Sent to',
|
|
||||||
value: data.recipientEmail,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
|
|
||||||
{isUserDetailsVisible && (
|
|
||||||
<>
|
|
||||||
<div className="mb-1 mt-2 flex flex-row space-x-2">
|
|
||||||
<Badge variant="neutral" className="text-muted-foreground">
|
|
||||||
IP: {auditLog.ipAddress ?? 'Unknown'}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<Badge variant="neutral" className="text-muted-foreground">
|
|
||||||
Browser: {extractBrowser(auditLog.userAgent)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{hasNextPage && (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
loading={isFetchingNextPage}
|
|
||||||
onClick={async () => fetchNextPage()}
|
|
||||||
>
|
|
||||||
Show more
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -13,7 +13,7 @@ type FriendlyStatus = {
|
|||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
||||||
PENDING: {
|
PENDING: {
|
||||||
label: 'Pending',
|
label: 'Pending',
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@@ -10,7 +10,7 @@ import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
|||||||
|
|
||||||
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
||||||
date: string | number | Date;
|
date: string | number | Date;
|
||||||
format?: DateTimeFormatOptions | string;
|
format?: DateTimeFormatOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,24 +22,13 @@ export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
|||||||
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
|
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
|
|
||||||
const formatDateTime = useCallback(
|
|
||||||
(date: DateTime) => {
|
|
||||||
if (typeof format === 'string') {
|
|
||||||
return date.toFormat(format);
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toLocaleString(format);
|
|
||||||
},
|
|
||||||
[format],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [localeDate, setLocaleDate] = useState(() =>
|
const [localeDate, setLocaleDate] = useState(() =>
|
||||||
formatDateTime(DateTime.fromJSDate(new Date(date)).setLocale(locale)),
|
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocaleDate(formatDateTime(DateTime.fromJSDate(new Date(date))));
|
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
|
||||||
}, [date, format, formatDateTime]);
|
}, [date, format]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={className} {...props}>
|
<span className={className} {...props}>
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ export const ZProfileFormSchema = z.object({
|
|||||||
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZTwoFactorAuthTokenSchema = z.object({
|
|
||||||
token: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TTwoFactorAuthTokenSchema = z.infer<typeof ZTwoFactorAuthTokenSchema>;
|
|
||||||
export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
|
export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
|
||||||
|
|
||||||
export type ProfileFormProps = {
|
export type ProfileFormProps = {
|
||||||
@@ -55,11 +50,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
|
||||||
|
|
||||||
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
|
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
|
||||||
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
|
|
||||||
trpc.profile.deleteAccount.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
|
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
|
||||||
try {
|
try {
|
||||||
@@ -141,7 +133,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<Button type="submit" loading={isSubmitting} className="self-end">
|
<Button type="submit" loading={isSubmitting}>
|
||||||
{isSubmitting ? 'Updating profile...' : 'Update profile'}
|
{isSubmitting ? 'Updating profile...' : 'Update profile'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png';
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { UserProfileSkeleton } from '../ui/user-profile-skeleton';
|
|
||||||
|
|
||||||
export const ZClaimPublicProfileFormSchema = z.object({
|
|
||||||
url: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.min(1, { message: 'Please enter a valid username.' })
|
|
||||||
.regex(/^[a-z0-9-]+$/, {
|
|
||||||
message: 'Username can only container alphanumeric characters and dashes.',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TClaimPublicProfileFormSchema = z.infer<typeof ZClaimPublicProfileFormSchema>;
|
|
||||||
|
|
||||||
export type ClaimPublicProfileDialogFormProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
onClaimed?: () => void;
|
|
||||||
user: User;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClaimPublicProfileDialogForm = ({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onClaimed,
|
|
||||||
user,
|
|
||||||
}: ClaimPublicProfileDialogFormProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [claimed, setClaimed] = useState(false);
|
|
||||||
|
|
||||||
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
|
|
||||||
|
|
||||||
const form = useForm<TClaimPublicProfileFormSchema>({
|
|
||||||
values: {
|
|
||||||
url: user.url || '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZClaimPublicProfileFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
|
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ url }: TClaimPublicProfileFormSchema) => {
|
|
||||||
try {
|
|
||||||
await updatePublicProfile({
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
setClaimed(true);
|
|
||||||
onClaimed?.();
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.PROFILE_URL_TAKEN) {
|
|
||||||
form.setError('url', {
|
|
||||||
type: 'manual',
|
|
||||||
message: 'This username is already taken',
|
|
||||||
});
|
|
||||||
} else if (error.code === AppErrorCode.PREMIUM_PROFILE_URL) {
|
|
||||||
form.setError('url', {
|
|
||||||
type: 'manual',
|
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
} else if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
|
|
||||||
toast({
|
|
||||||
title: 'An error occurred',
|
|
||||||
description: error.userMessage ?? error.message,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to save your details. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent position="center" className="max-w-lg overflow-hidden">
|
|
||||||
{!claimed && (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="font-semi-bold text-center text-xl">
|
|
||||||
Introducing public profiles!
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="text-center">
|
|
||||||
Reserve your Documenso public profile username
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Image src={profileClaimTeaserImage} alt="profile claim teaser" />
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
className={cn(
|
|
||||||
'to-background -mt-32 flex w-full flex-col bg-gradient-to-b from-transparent to-15% pt-16 md:-mt-44',
|
|
||||||
)}
|
|
||||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
|
||||||
>
|
|
||||||
<fieldset className="-mt-6 flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="url"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Public profile username</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Input type="text" className="mb-2 mt-2" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
|
|
||||||
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block truncate rounded-md px-2 py-1 text-sm">
|
|
||||||
{baseUrl.host}/u/{field.value || '<username>'}
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
|
||||||
<Button type="submit" loading={isSubmitting}>
|
|
||||||
Claim your username
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{claimed && (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="font-semi-bold text-center text-xl">All set!</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="text-center">
|
|
||||||
We will let you know as soon as this features is launched
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<UserProfileSkeleton className="mt-4" user={user} rows={1} />
|
|
||||||
|
|
||||||
<div className="to-background -mt-12 flex w-full flex-col items-center bg-gradient-to-b from-transparent to-15% px-4 pt-8 md:-mt-12">
|
|
||||||
<Button className="w-full" onClick={() => onOpenChange?.(false)}>
|
|
||||||
Can't wait!
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -196,11 +195,9 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="email" {...field} />
|
<Input type="email" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -212,19 +209,9 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput {...field} />
|
<PasswordInput {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<p className="mt-2 text-right">
|
|
||||||
<Link
|
|
||||||
href="/forgot-password"
|
|
||||||
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
|
||||||
>
|
|
||||||
Forgot your password?
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,10 +46,9 @@ type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
|
|||||||
|
|
||||||
export type ApiTokenFormProps = {
|
export type ApiTokenFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
teamId?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ApiTokenForm = ({ className, teamId }: ApiTokenFormProps) => {
|
export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [, copy] = useCopyToClipboard();
|
const [, copy] = useCopyToClipboard();
|
||||||
@@ -97,7 +96,6 @@ export const ApiTokenForm = ({ className, teamId }: ApiTokenFormProps) => {
|
|||||||
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
|
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
|
||||||
try {
|
try {
|
||||||
await createTokenMutation({
|
await createTokenMutation({
|
||||||
teamId,
|
|
||||||
tokenName,
|
tokenName,
|
||||||
expirationDate: noExpirationDate ? null : expirationDate,
|
expirationDate: noExpirationDate ? null : expirationDate,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,463 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { signIn } from 'next-auth/react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
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 { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { UserProfileSkeleton } from '~/components/ui/user-profile-skeleton';
|
|
||||||
import { UserProfileTimur } from '~/components/ui/user-profile-timur';
|
|
||||||
|
|
||||||
const SIGN_UP_REDIRECT_PATH = '/documents';
|
|
||||||
|
|
||||||
type SignUpStep = 'BASIC_DETAILS' | 'CLAIM_USERNAME';
|
|
||||||
|
|
||||||
export const ZSignUpFormV2Schema = z
|
|
||||||
.object({
|
|
||||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
|
||||||
email: z.string().email().min(1),
|
|
||||||
password: ZPasswordSchema,
|
|
||||||
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
|
||||||
url: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.min(1, { message: 'We need a username to create your profile' })
|
|
||||||
.regex(/^[a-z0-9-]+$/, {
|
|
||||||
message: 'Username can only container alphanumeric characters and dashes.',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.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',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TSignUpFormV2Schema = z.infer<typeof ZSignUpFormV2Schema>;
|
|
||||||
|
|
||||||
export type SignUpFormV2Props = {
|
|
||||||
className?: string;
|
|
||||||
initialEmail?: string;
|
|
||||||
isGoogleSSOEnabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SignUpFormV2 = ({
|
|
||||||
className,
|
|
||||||
initialEmail,
|
|
||||||
isGoogleSSOEnabled,
|
|
||||||
}: SignUpFormV2Props) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const analytics = useAnalytics();
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
const [step, setStep] = useState<SignUpStep>('BASIC_DETAILS');
|
|
||||||
|
|
||||||
const utmSrc = searchParams?.get('utm_source') ?? null;
|
|
||||||
|
|
||||||
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
|
|
||||||
|
|
||||||
const form = useForm<TSignUpFormV2Schema>({
|
|
||||||
values: {
|
|
||||||
name: '',
|
|
||||||
email: initialEmail ?? '',
|
|
||||||
password: '',
|
|
||||||
signature: '',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
mode: 'onBlur',
|
|
||||||
resolver: zodResolver(ZSignUpFormV2Schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
|
||||||
|
|
||||||
const name = form.watch('name');
|
|
||||||
const url = form.watch('url');
|
|
||||||
|
|
||||||
// To continue we need to make sure name, email, password and signature are valid
|
|
||||||
const canContinue =
|
|
||||||
form.formState.dirtyFields.name &&
|
|
||||||
form.formState.errors.name === undefined &&
|
|
||||||
form.formState.dirtyFields.email &&
|
|
||||||
form.formState.errors.email === undefined &&
|
|
||||||
form.formState.dirtyFields.password &&
|
|
||||||
form.formState.errors.password === undefined &&
|
|
||||||
form.formState.dirtyFields.signature &&
|
|
||||||
form.formState.errors.signature === undefined;
|
|
||||||
|
|
||||||
console.log({ formSTate: form.formState });
|
|
||||||
|
|
||||||
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => {
|
|
||||||
try {
|
|
||||||
await signup({ name, email, password, signature, url });
|
|
||||||
|
|
||||||
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 Sign Up', {
|
|
||||||
email,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
custom_campaign_params: { src: utmSrc },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.PROFILE_URL_TAKEN) {
|
|
||||||
form.setError('url', {
|
|
||||||
type: 'manual',
|
|
||||||
message: 'This username has already been taken',
|
|
||||||
});
|
|
||||||
} else if (error.code === AppErrorCode.PREMIUM_PROFILE_URL) {
|
|
||||||
form.setError('url', {
|
|
||||||
type: 'manual',
|
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
} else if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
|
||||||
toast({
|
|
||||||
title: 'An error occurred',
|
|
||||||
description: err.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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSignUpWithGoogleClick = async () => {
|
|
||||||
try {
|
|
||||||
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
|
|
||||||
} catch (err) {
|
|
||||||
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={cn('flex justify-center gap-x-12', className)}>
|
|
||||||
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
|
|
||||||
<div className="absolute -inset-8 -z-[2] backdrop-blur">
|
|
||||||
<Image
|
|
||||||
src={communityCardsImage}
|
|
||||||
fill={true}
|
|
||||||
alt="community-cards"
|
|
||||||
className="dark:brightness-95 dark:contrast-[70%] dark:invert"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
|
|
||||||
|
|
||||||
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
|
|
||||||
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
|
|
||||||
User profiles are coming soon!
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{step === 'BASIC_DETAILS' ? (
|
|
||||||
<motion.div className="w-full max-w-md" layoutId="user-profile">
|
|
||||||
<UserProfileTimur
|
|
||||||
rows={2}
|
|
||||||
className="bg-background border-border rounded-2xl border shadow-md"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<motion.div className="w-full max-w-md" layoutId="user-profile">
|
|
||||||
<UserProfileSkeleton
|
|
||||||
user={{ name, url }}
|
|
||||||
rows={2}
|
|
||||||
className="bg-background border-border rounded-2xl border shadow-md"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(800px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
|
|
||||||
{step === 'BASIC_DETAILS' && (
|
|
||||||
<div className="h-20">
|
|
||||||
<h1 className="text-2xl font-semibold">Create a new account</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
Create your account and start using state-of-the-art document signing. Open and
|
|
||||||
beautiful signing is within your grasp.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'CLAIM_USERNAME' && (
|
|
||||||
<div className="h-20">
|
|
||||||
<h1 className="text-2xl font-semibold">Claim your username now</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
You will get notified & be able to set up your documenso public profile when we launch
|
|
||||||
the feature.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<hr className="-mx-6 my-4" />
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
className="flex w-full flex-1 flex-col gap-y-4"
|
|
||||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
|
||||||
>
|
|
||||||
{step === 'BASIC_DETAILS' && (
|
|
||||||
<fieldset
|
|
||||||
className={cn(
|
|
||||||
'flex h-[500px] w-full flex-col gap-y-4',
|
|
||||||
isGoogleSSOEnabled && 'h-[600px]',
|
|
||||||
)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Full Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="text" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email Address</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="email" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Password</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="signature"
|
|
||||||
render={({ field: { onChange } }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Sign Here</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<SignaturePad
|
|
||||||
className="h-36 w-full"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
containerClassName="mt-2 rounded-lg border bg-background"
|
|
||||||
onChange={(v) => onChange(v ?? '')}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isGoogleSSOEnabled && (
|
|
||||||
<>
|
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
|
||||||
<div className="bg-border h-px flex-1" />
|
|
||||||
<span className="text-muted-foreground bg-transparent">Or</span>
|
|
||||||
<div className="bg-border h-px flex-1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="lg"
|
|
||||||
variant={'outline'}
|
|
||||||
className="bg-background text-muted-foreground border"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={onSignUpWithGoogleClick}
|
|
||||||
>
|
|
||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
|
||||||
Sign Up with Google
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4 text-sm">
|
|
||||||
Already have an account?{' '}
|
|
||||||
<Link href="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
|
|
||||||
Sign in instead
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'CLAIM_USERNAME' && (
|
|
||||||
<fieldset
|
|
||||||
className={cn(
|
|
||||||
'flex h-[500px] w-full flex-col gap-y-4',
|
|
||||||
isGoogleSSOEnabled && 'h-[600px]',
|
|
||||||
)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="url"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Public profile username</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Input type="text" className="mb-2 mt-2 lowercase" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
|
|
||||||
<div className="bg-muted/50 border-border text-muted-foreground mt-2 inline-block truncate rounded-md border px-2 py-1 text-sm lowercase">
|
|
||||||
{baseUrl.host}/u/{field.value || '<username>'}
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
{step === 'BASIC_DETAILS' && (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<span className="font-medium">Basic details</span> 1/2
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'CLAIM_USERNAME' && (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<span className="font-medium">Claim username</span> 2/2
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-foreground/40 relative mt-4 h-1.5 rounded-full">
|
|
||||||
<motion.div
|
|
||||||
layout="size"
|
|
||||||
layoutId="document-flow-container-step"
|
|
||||||
className="bg-documenso absolute inset-y-0 left-0 rounded-full"
|
|
||||||
style={{
|
|
||||||
width: step === 'BASIC_DETAILS' ? '50%' : '100%',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
{/* Go back button, disabled if step is basic details */}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="lg"
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={step === 'BASIC_DETAILS'}
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
onClick={() => setStep('BASIC_DETAILS')}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Continue button */}
|
|
||||||
{step === 'BASIC_DETAILS' && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="lg"
|
|
||||||
className="flex-1 disabled:cursor-not-allowed"
|
|
||||||
disabled={!canContinue}
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
onClick={() => setStep('CLAIM_USERNAME')}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sign up button */}
|
|
||||||
{step === 'CLAIM_USERNAME' && (
|
|
||||||
<Button
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
disabled={!form.formState.isValid}
|
|
||||||
type="submit"
|
|
||||||
size="lg"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Complete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { File, User2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
|
||||||
import { VerifiedIcon } from '@documenso/ui/icons/verified';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
export type UserProfileSkeletonProps = {
|
|
||||||
className?: string;
|
|
||||||
user: Pick<User, 'name' | 'url'>;
|
|
||||||
rows?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => {
|
|
||||||
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="border-border bg-background text-muted-foreground inline-flex items-center rounded-md border px-2.5 py-1.5 text-sm">
|
|
||||||
<span>{baseUrl.host}/u/</span>
|
|
||||||
<span className="inline-block max-w-[8rem] truncate lowercase">{user.url}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="bg-primary/10 rounded-full p-1.5">
|
|
||||||
<div className="bg-background flex h-20 w-20 items-center justify-center rounded-full border-2">
|
|
||||||
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="flex items-center justify-center gap-x-2">
|
|
||||||
<h2 className="max-w-[12rem] truncate text-2xl font-semibold">{user.name}</h2>
|
|
||||||
|
|
||||||
<VerifiedIcon className="text-primary h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dark:bg-foreground/30 mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300" />
|
|
||||||
<div className="dark:bg-foreground/20 mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 w-full">
|
|
||||||
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
|
|
||||||
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
|
|
||||||
Documents
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Array(rows)
|
|
||||||
.fill(0)
|
|
||||||
.map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="bg-background flex items-center justify-between gap-x-6 p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
|
|
||||||
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Button type="button" size="sm" className="pointer-events-none w-32">
|
|
||||||
Sign
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
import { File } from 'lucide-react';
|
|
||||||
|
|
||||||
import timurImage from '@documenso/assets/images/timur.png';
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { VerifiedIcon } from '@documenso/ui/icons/verified';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
export type UserProfileTimurProps = {
|
|
||||||
className?: string;
|
|
||||||
rows?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UserProfileTimur = ({ className, rows = 2 }: UserProfileTimurProps) => {
|
|
||||||
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="border-border bg-background text-muted-foreground inline-block rounded-md border px-2.5 py-1.5 text-sm">
|
|
||||||
{baseUrl.host}/u/timur
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Image
|
|
||||||
src={timurImage}
|
|
||||||
className="h-20 w-20 rounded-full"
|
|
||||||
alt="image of timur ercan founder of documenso"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="flex items-center justify-center gap-x-2">
|
|
||||||
<h2 className="text-2xl font-semibold">Timur Ercan</h2>
|
|
||||||
|
|
||||||
<VerifiedIcon className="text-primary h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4 max-w-[40ch] text-center text-sm">Hey I’m Timur</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1 max-w-[40ch] text-center text-sm">
|
|
||||||
Pick any of the following agreements below and start signing to get started
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 w-full">
|
|
||||||
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
|
|
||||||
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
|
|
||||||
Documents
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Array(rows)
|
|
||||||
.fill(0)
|
|
||||||
.map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="bg-background flex items-center justify-between gap-x-6 p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
|
|
||||||
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Button type="button" size="sm" className="pointer-events-none w-32">
|
|
||||||
Sign
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user