mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
45 Commits
v1.5.0-rc.
...
v1.5.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 328d16483c | |||
| 6dd2abfe51 | |||
| 452545dab1 | |||
| 437410c73a | |||
| 36a95f6153 | |||
| 0f03ad4a6b | |||
| 8674ad4c88 | |||
| 00c36782ff | |||
| 665ccd7628 | |||
| e5fe3d897d | |||
| bfb1c65f98 | |||
| 3b8b87a90b | |||
| 819e58dd61 | |||
| 8c435d48b7 | |||
| 85885b5ad5 | |||
| 5cc4204788 | |||
| e226f012a9 | |||
| 4ad722ba00 | |||
| 1927cc4067 | |||
| c2ec092404 | |||
| ebe23335f8 | |||
| e5be219b99 | |||
| aa87a86a5f | |||
| 1cc7c95a33 | |||
| 9f576eb47c | |||
| bd82ad7a0b | |||
| cc1b888174 | |||
| 5576cdc2b0 | |||
| ecc9dc63ea | |||
| 11fe380338 | |||
| f376c7b951 | |||
| 90477dfd00 | |||
| dd81f946b4 | |||
| f4e1df7f3a | |||
| 3510d8b6b0 | |||
| 1590fa9457 | |||
| ce09c32bf5 | |||
| e3e2cfbcfd | |||
| 8ab77e0e55 | |||
| 837ff531a9 | |||
| b225cc8139 | |||
| b498f8edb7 | |||
| b1261510d2 | |||
| 65d762dd4b | |||
| deea6b1535 |
@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
||||
|
||||
To run Documenso locally, you will need
|
||||
|
||||
- Node.js
|
||||
- Node.js (v18 or above)
|
||||
- Postgres SQL Database
|
||||
- Docker (optional)
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@ We are launching templates for Documenso! Templates are an easy way to reuse doc
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
POV: You are a diligent german and create custom receipts with Documenso.
|
||||
POV: You are a diligent german and create custom receipts with Documenso
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
|
||||
53
apps/marketing/content/blog/launch-week-2-day-3.mdx
Normal file
53
apps/marketing/content/blog/launch-week-2-day-3.mdx
Normal file
@ -0,0 +1,53 @@
|
||||
---
|
||||
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
|
||||
63
apps/marketing/content/blog/launch-week-2-day-4.mdx
Normal file
63
apps/marketing/content/blog/launch-week-2-day-4.mdx
Normal file
@ -0,0 +1,63 @@
|
||||
---
|
||||
title: Launch Week II - Day 4 - Webhooks and Zapier
|
||||
description: If you want to integrate Documenso without fiddling with the API, we got you as well. You can now integrate Documenso via Zapier, included in all plans!
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-02-29
|
||||
tags:
|
||||
- Launch Week
|
||||
- Zapier
|
||||
- Webhooks
|
||||
---
|
||||
|
||||
<video
|
||||
id="vid"
|
||||
width="100%"
|
||||
src="https://github.com/documenso/design/assets/1309312/3b60789d-8d27-4c66-ae5c-179e33c2e3e6"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
></video>
|
||||
|
||||
> TLDR; Zapier Integration is now available for all plans.
|
||||
|
||||
## Introducing Zapier for Documenso
|
||||
|
||||
Day 4 🥳 Yesterday we introduced our [public API](https://documen.so/day3) for developers to build on Documenso. If you are not a developer or simple want a quicker integration this is for you: Documenso now support Zapier Integrations! Just connect your Documenso account via a simple login flow with Zapier and you will have access to Zapier's universe of integrations 💫 The integration currently supports:
|
||||
|
||||
- Document Created ([https://documen.so/zapier-created](https://documen.so/zapier-created))
|
||||
- Document Sent ([Chttps://documen.so/zapier-sent](https://documen.so/zapier-sent))
|
||||
- Document Opened ([https://documen.so/zapier-opened](https://documen.so/zapier-opened))
|
||||
- Document Signed ([https://documen.so/zapier-signed](https://documen.so/zapier-signed))
|
||||
- Document Completed ([https://documen.so/zapier-completed](https://documen.so/zapier-completed))
|
||||
|
||||
> ⚡️ You can create your own Zaps here: https://zapier.com/apps/documenso/integrations
|
||||
|
||||
Each event comes with extensive meta-data for you to use in Zapier. Missing something? Reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord). We're always here and would love to hear from you :)
|
||||
|
||||
## Also Introducing for Documenso: Webhooks
|
||||
|
||||
To build the Zapier Integration, we needed a good webhooks concept, so we added that as well. Together with your Zaps, you can also now create customer webhooks in Documenso. You can try webhooks here for free: [https://documen.so/webhooks](https://documen.so/webhooks)
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/hooks.png"
|
||||
width="1260"
|
||||
height="630"
|
||||
alt="Webhooks UI"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
Create unlimited custom webhooks with each plan.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
## Pricing
|
||||
|
||||
Just like the API, we consider the Zapier integration and webhooks part of the open Documenso platform. Zapier is **available for all Documenso plans**, including free! [Login now](https://documen.so/login) to check it out.
|
||||
|
||||
> 🚨 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 4! Documenso now supports @Zapier 🤯 https://documen.so/day4"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
61
apps/marketing/content/blog/launch-week-2-day-5.mdx
Normal file
61
apps/marketing/content/blog/launch-week-2-day-5.mdx
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Launch Week II - Day 5 - Documenso Profiles
|
||||
description: Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles will launch as soon as they are shiny.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-03-01
|
||||
tags:
|
||||
- Launch Week
|
||||
- Profiles
|
||||
---
|
||||
|
||||
<video
|
||||
id="vid"
|
||||
width="100%"
|
||||
src="https://github.com/documenso/design/assets/1309312/89643ae2-2aa9-484c-a522-a0e35097c469"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
></video>
|
||||
|
||||
> TLDR; Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles launch as soon as they are shiny.
|
||||
|
||||
## Introducing Documenso Profile Links
|
||||
|
||||
Day 5 - The Finale 🔥
|
||||
|
||||
Signing documents has always been between humans, and signing something together should be as frictionless as possible. It should also be async, so you don't force your counterpart to jog to their device to send something when you are ready. Today we are announcing the new Documenso Profiles:
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/profile.png"
|
||||
width="1260"
|
||||
height="813"
|
||||
alt="Timur's Documenso Profile"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
Async > Sync: Add public templates to your Documenso Link and let people sign whenever they are ready.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
Documenso profiles work with your existing templates. You can just add them to your public profile to let everyone with your link sign them. With profiles, we want to bring back the human aspect of signing.
|
||||
|
||||
By making profiles public, you can always access what your counterparty offers and make them more visible in the process. Long-term, we plan to add more to profiles to help you ensure the person you are dealing with is who they claim to be. Documenso wants to be the trust layer of the internet, and we want to start at the very fundamental level: The individual transaction.
|
||||
|
||||
Profiles are our first step towards bringing more trust into everything, simply by making the use of signing more frictionless. As there is more and more content of questionable origin out there, we want to support you in making it clear what you send out and what not.
|
||||
|
||||
## Pricing and Claiming
|
||||
|
||||
Documenso profile username can be claimed starting today. Documenso profiles will launch as soon as we are happy with the details ✨
|
||||
|
||||
- Long usernames (6 characters or more) come free with every account, e.g. **documenso.com/u/timurercan**
|
||||
- Short usernames (5 characters or fewer) or less require any paid account ([Early Adopter](https://documen.so/claim-early-adopters-plan), [Teams](https://documen.so/teams) or Enterprise): **e.g., documenso.com/u/timur**
|
||||
|
||||
You can claim your username here: [https://documen.so/claim](https://documen.so/claim)
|
||||
|
||||
> 🚨 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 5! You can now claim your username for the upcoming profile links 😮 https://documen.so/day5"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
BIN
apps/marketing/public/blog/hooks.png
Normal file
BIN
apps/marketing/public/blog/hooks.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
BIN
apps/marketing/public/blog/profile.png
Normal file
BIN
apps/marketing/public/blog/profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 365 KiB |
@ -2,8 +2,12 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { Footer } from '~/components/(marketing)/footer';
|
||||
@ -17,6 +21,10 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
const pathname = usePathname();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const showProfilesAnnouncementBar = getFlag('marketing_profiles_announcement_bar');
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
setScrollY(window.scrollY);
|
||||
@ -38,6 +46,31 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
'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 text-white">
|
||||
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" />
|
||||
</div>
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ export default async function SinglePlayerModeSuccessPage({
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const signatures = await getRecipientSignatures({ recipientId: document.Recipient.id });
|
||||
const signatures = await getRecipientSignatures({ recipientId: document.Recipient[0].id });
|
||||
|
||||
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">
|
||||
Create a{' '}
|
||||
<Link
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
|
||||
target="_blank"
|
||||
className="hover:text-foreground/80 font-semibold transition-colors"
|
||||
>
|
||||
|
||||
@ -40,9 +40,9 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
||||
onClick={onSignUpClick}
|
||||
>
|
||||
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">
|
||||
$30/mo. forever!
|
||||
Claim Community 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">
|
||||
$30/mo
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import Link from 'next/link';
|
||||
import LogoImage from '@documenso/assets/logo.png';
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { HamburgerMenu } from './mobile-hamburger';
|
||||
import { MobileNavigation } from './mobile-navigation';
|
||||
@ -68,12 +69,18 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://app.documenso.com/signin"
|
||||
href="https://app.documenso.com/signin?utm_source=marketing-header"
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
|
||||
>
|
||||
Sign in
|
||||
</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>
|
||||
|
||||
<HamburgerMenu
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Variants, motion } from 'framer-motion';
|
||||
import type { Variants } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
import { match } from 'ts-pattern';
|
||||
@ -113,9 +114,9 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
className="rounded-full bg-transparent backdrop-blur-sm"
|
||||
onClick={onSignUpClick}
|
||||
>
|
||||
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">
|
||||
$30/mo. forever!
|
||||
Claim Community 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">
|
||||
$30/mo
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@ -224,8 +225,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
<span className="bg-primary text-black">
|
||||
(in a non-legally binding, but heartfelt way)
|
||||
</span>{' '}
|
||||
and lock in the early supporter plan for forever, including everything we build this
|
||||
year.
|
||||
and lock in the community plan for forever, including everything we build this year.
|
||||
</p>
|
||||
|
||||
<div className="flex h-24 items-center">
|
||||
|
||||
@ -47,9 +47,13 @@ export const MENU_NAVIGATION_LINKS = [
|
||||
text: 'Privacy',
|
||||
},
|
||||
{
|
||||
href: 'https://app.documenso.com/signin',
|
||||
href: 'https://app.documenso.com/signin?utm_source=marketing-header',
|
||||
text: 'Sign in',
|
||||
},
|
||||
{
|
||||
href: 'https://app.documenso.com/signup?utm_source=marketing-header',
|
||||
text: 'Sign up',
|
||||
},
|
||||
];
|
||||
|
||||
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
|
||||
|
||||
@ -83,7 +83,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
</p>
|
||||
|
||||
<Button className="rounded-full text-base" asChild>
|
||||
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank" className="mt-6">
|
||||
<Link
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-free-plan`}
|
||||
target="_blank"
|
||||
className="mt-6"
|
||||
>
|
||||
Signup Now
|
||||
</Link>
|
||||
</Button>
|
||||
@ -114,7 +118,10 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
</p>
|
||||
|
||||
<Button className="mt-6 rounded-full text-base" asChild>
|
||||
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank">
|
||||
<Link
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-community`}
|
||||
target="_blank"
|
||||
>
|
||||
Signup Now
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@ -55,7 +55,7 @@ export const SinglePlayerModeSuccess = ({
|
||||
|
||||
<SigningCard3D
|
||||
className="mt-8"
|
||||
name={document.Recipient.name || document.Recipient.email}
|
||||
name={document.Recipient[0].name || document.Recipient[0].email}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
@ -65,7 +65,7 @@ export const SinglePlayerModeSuccess = ({
|
||||
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
|
||||
<DocumentShareButton
|
||||
documentId={document.id}
|
||||
token={document.Recipient.token}
|
||||
token={document.Recipient[0].token}
|
||||
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">
|
||||
Create a{' '}
|
||||
<Link
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
|
||||
target="_blank"
|
||||
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"
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<h3 className="text-2xl font-semibold">Sign up for the early adopters plan</h3>
|
||||
<h3 className="text-xl font-semibold">Sign up to Community Plan</h3>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
with Timur Ercan & Lucas Smith from Documenso
|
||||
</p>
|
||||
@ -208,7 +208,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div key="email">
|
||||
<label htmlFor="email" className="text-foreground text-lg font-semibold lg:text-xl">
|
||||
<label htmlFor="email" className="text-foreground font-medium ">
|
||||
What’s your email?
|
||||
</label>
|
||||
|
||||
@ -220,7 +220,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder=""
|
||||
placeholder="your@example.com"
|
||||
className="bg-background w-full pr-16"
|
||||
disabled={isSubmitting}
|
||||
onKeyDown={(e) =>
|
||||
@ -265,11 +265,8 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
transform: 'translateX(25%)',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-foreground text-lg font-semibold lg:text-xl"
|
||||
>
|
||||
and your name?
|
||||
<label htmlFor="name" className="text-foreground font-medium ">
|
||||
And your name?
|
||||
</label>
|
||||
|
||||
<Controller
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||
import { DocumentsPageView } from './documents-page-view';
|
||||
import { UpcomingProfileClaimTeaser } from './upcoming-profile-claim-teaser';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||
@ -11,6 +14,12 @@ export const metadata: Metadata = {
|
||||
title: 'Documents',
|
||||
};
|
||||
|
||||
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
return <DocumentsPageView searchParams={searchParams} />;
|
||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
return (
|
||||
<>
|
||||
<UpcomingProfileClaimTeaser user={user} />
|
||||
<DocumentsPageView searchParams={searchParams} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
'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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,46 @@
|
||||
'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>{user.url ? 'Update your profile' : 'Claim your profile'}</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
{user.url
|
||||
? 'Profiles are coming soon! Update your profile username to reserve your corner of the signing revolution.'
|
||||
: '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)}>{user.url ? 'Update Now' : 'Claim Now'}</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<ClaimPublicProfileDialogForm open={open} onOpenChange={setOpen} user={user} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -5,6 +5,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { ProfileForm } from '~/components/forms/profile';
|
||||
|
||||
import { ClaimProfileAlertDialog } from './claim-profile-alert-dialog';
|
||||
import { DeleteAccountDialog } from './delete-account-dialog';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@ -18,9 +19,13 @@ export default async function ProfileSettingsPage() {
|
||||
<div>
|
||||
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
||||
|
||||
<ProfileForm className="max-w-xl" user={user} />
|
||||
<ProfileForm className="mb-8 max-w-xl" user={user} />
|
||||
|
||||
<DeleteAccountDialog className="mt-8 max-w-xl" user={user} />
|
||||
<ClaimProfileAlertDialog className="max-w-xl" user={user} />
|
||||
|
||||
<hr className="my-4 max-w-xl" />
|
||||
|
||||
<DeleteAccountDialog className="max-w-xl" user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
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';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@ -9,11 +12,14 @@ export const metadata: Metadata = {
|
||||
export default function SettingsSecurityActivityPage() {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Security activity</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
View all recent security activity related to your account.
|
||||
</p>
|
||||
<SettingsHeader
|
||||
title="Security activity"
|
||||
subtitle="View all recent security activity related to your account."
|
||||
>
|
||||
<div>
|
||||
<ActivityPageBackButton />
|
||||
</div>
|
||||
</SettingsHeader>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
|
||||
@ -9,17 +9,19 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Email sent!</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="text-4xl font-semibold">Email sent!</h1>
|
||||
|
||||
<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 inbox
|
||||
shortly.
|
||||
</p>
|
||||
<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
|
||||
inbox shortly.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/signin">Return to sign in</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/signin">Return to sign in</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,22 +9,24 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Forgot your password?</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="text-3xl font-semibold">Forgot your password?</h1>
|
||||
|
||||
<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
|
||||
password.
|
||||
</p>
|
||||
<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
|
||||
password.
|
||||
</p>
|
||||
|
||||
<ForgotPasswordForm className="mt-4" />
|
||||
<ForgotPasswordForm className="mt-4" />
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Remembered your password?{' '}
|
||||
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign In
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Remembered your password?{' '}
|
||||
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,9 +10,9 @@ type UnauthenticatedLayoutProps = {
|
||||
|
||||
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
|
||||
return (
|
||||
<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 className="relative flex w-full max-w-md items-center gap-x-24">
|
||||
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
||||
<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">
|
||||
<div>
|
||||
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
||||
<Image
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
@ -20,7 +20,7 @@ export default function UnauthenticatedLayout({ children }: UnauthenticatedLayou
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">{children}</div>
|
||||
<div className="relative w-full">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@ -19,19 +19,21 @@ export default async function ResetPasswordPage({ params: { token } }: ResetPass
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h1 className="text-4xl font-semibold">Reset Password</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<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">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,17 +9,19 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Unable to reset password</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="text-3xl font-semibold">Unable to reset password</h1>
|
||||
|
||||
<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 you
|
||||
have still forgotten your password, please request a new reset link.
|
||||
</p>
|
||||
<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
|
||||
you have still forgotten your password, please request a new reset link.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/signin">Return to sign in</Link>
|
||||
</Button>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/signin">Return to sign in</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,36 +30,27 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
|
||||
<h1 className="text-2xl font-semibold">Sign in to your account</h1>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||
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 className="text-muted-foreground mt-2 text-sm">
|
||||
Welcome back, we are lucky to have you.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2.5 text-center">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</p>
|
||||
<hr className="-mx-6 my-4" />
|
||||
|
||||
<SignInForm 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-documenso-700 duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { env } from 'next-runtime-env';
|
||||
@ -7,7 +6,7 @@ import { env } from 'next-runtime-env';
|
||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
import { SignUpFormV2 } from '~/components/forms/v2/signup';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sign Up',
|
||||
@ -34,26 +33,10 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||
|
||||
<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>
|
||||
<SignUpFormV2
|
||||
className="w-screen max-w-screen-2xl px-4 md:px-16"
|
||||
initialEmail={email || undefined}
|
||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -29,16 +29,18 @@ export default async function AcceptInvitationPage({
|
||||
|
||||
if (!teamMemberInvite) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invalid token</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="text-4xl font-semibold">Invalid token</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
This token is invalid or has expired. Please contact your team for a new invitation.
|
||||
</p>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,16 +22,18 @@ export default async function VerifyTeamEmailPage({ params: { token } }: VerifyT
|
||||
|
||||
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -25,17 +25,19 @@ export default async function VerifyTeamTransferPage({
|
||||
|
||||
if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||
|
||||
<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
|
||||
request.
|
||||
</p>
|
||||
<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
|
||||
request.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,23 +4,25 @@ import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-
|
||||
|
||||
export default function UnverifiedAccount() {
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="">
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="flex items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="">
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
To gain access to your account, please confirm your email address by clicking on the
|
||||
confirmation link from your inbox.
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-4">
|
||||
To gain access to your account, please confirm your email address by clicking on the
|
||||
confirmation link from your inbox.
|
||||
</p>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<SendConfirmationEmailForm />
|
||||
<SendConfirmationEmailForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -14,15 +14,17 @@ export type PageProps = {
|
||||
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-4 text-red-300">
|
||||
<XOctagon />
|
||||
</div>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<div className="mb-4 text-red-300">
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -31,22 +33,24 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
|
||||
|
||||
if (verified === null) {
|
||||
return (
|
||||
<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 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>
|
||||
<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>
|
||||
<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>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -54,17 +58,41 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
|
||||
|
||||
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="mr-4 mt-1 hidden md:block">
|
||||
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</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.
|
||||
Your email has been successfully confirmed! You can now use all features of Documenso.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
@ -72,26 +100,6 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,22 +11,26 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function EmailVerificationWithoutTokenPage() {
|
||||
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 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">Uh oh! Looks like you're missing a token</h2>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">
|
||||
Uh oh! Looks like you're missing a token
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
It seems that there is no token provided, if you are trying to verify your email please
|
||||
follow the link in your email.
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-4">
|
||||
It seems that there is no token provided, if you are trying to verify your email please
|
||||
follow the link in your email.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
'use client';
|
||||
|
||||
export { OpenApiDocsPage as default } from '@documenso/api/v1/api-documentation';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const Docs = dynamic(async () => import('@documenso/api/v1/api-documentation'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function OpenApiDocsPage() {
|
||||
return <Docs />;
|
||||
}
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type SettingsHeaderProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SettingsHeader = ({ children, title, subtitle }: SettingsHeaderProps) => {
|
||||
export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className={cn('flex flex-row items-center justify-between', className)}>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
|
||||
|
||||
197
apps/web/src/components/forms/public-profile-claim-dialog.tsx
Normal file
197
apps/web/src/components/forms/public-profile-claim-dialog.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
'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,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@ -195,9 +196,11 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@ -209,9 +212,19 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</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 />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
460
apps/web/src/components/forms/v2/signup.tsx
Normal file
460
apps/web/src/components/forms/v2/signup.tsx
Normal file
@ -0,0 +1,460 @@
|
||||
'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;
|
||||
|
||||
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' || 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>
|
||||
);
|
||||
};
|
||||
84
apps/web/src/components/ui/user-profile-skeleton.tsx
Normal file
84
apps/web/src/components/ui/user-profile-skeleton.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
87
apps/web/src/components/ui/user-profile-timur.tsx
Normal file
87
apps/web/src/components/ui/user-profile-timur.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
@ -32,6 +32,7 @@ export function PostHogPageview() {
|
||||
// Do nothing.
|
||||
});
|
||||
},
|
||||
custom_campaign_params: ['src'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -8,3 +8,5 @@ import { OpenAPIV1 } from '@documenso/api/v1/openapi';
|
||||
export const OpenApiDocsPage = () => {
|
||||
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
|
||||
};
|
||||
|
||||
export default OpenApiDocsPage;
|
||||
|
||||
@ -34,6 +34,7 @@ export const manualLogin = async ({
|
||||
};
|
||||
|
||||
export const manualSignout = async ({ page }: ManualLoginOptions) => {
|
||||
await page.waitForTimeout(1000);
|
||||
await page.getByTestId('menu-switcher').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
||||
|
||||
@ -29,7 +29,10 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Sign Up', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click();
|
||||
await page.getByLabel('Public profile username').fill('username-123');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete', exact: true }).click();
|
||||
|
||||
await page.waitForURL('/unverified-account');
|
||||
|
||||
|
||||
BIN
packages/assets/images/background-lw-2.png
Normal file
BIN
packages/assets/images/background-lw-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
BIN
packages/assets/images/community-cards.png
Normal file
BIN
packages/assets/images/community-cards.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 MiB |
BIN
packages/assets/images/profile-claim-teaser.png
Normal file
BIN
packages/assets/images/profile-claim-teaser.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
packages/assets/images/timur.png
Normal file
BIN
packages/assets/images/timur.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@ -25,6 +25,7 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_teams: true,
|
||||
app_document_page_view_history_sheet: false,
|
||||
marketing_header_single_player_mode: false,
|
||||
marketing_profiles_announcement_bar: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@ -18,6 +18,8 @@ export enum AppErrorCode {
|
||||
'RETRY_EXCEPTION' = 'RetryException',
|
||||
'SCHEMA_FAILED' = 'SchemaFailed',
|
||||
'TOO_MANY_REQUESTS' = 'TooManyRequests',
|
||||
'PROFILE_URL_TAKEN' = 'ProfileUrlTaken',
|
||||
'PREMIUM_PROFILE_URL' = 'PremiumProfileUrl',
|
||||
}
|
||||
|
||||
const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
|
||||
@ -32,6 +34,8 @@ const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
|
||||
[AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR',
|
||||
[AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR',
|
||||
[AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS',
|
||||
[AppErrorCode.PROFILE_URL_TAKEN]: 'BAD_REQUEST',
|
||||
[AppErrorCode.PREMIUM_PROFILE_URL]: 'BAD_REQUEST',
|
||||
};
|
||||
|
||||
export const ZAppErrorJsonSchema = z.object({
|
||||
|
||||
@ -70,6 +70,6 @@ export const getDocumentAndRecipientByToken = async ({
|
||||
|
||||
return {
|
||||
...result,
|
||||
Recipient: result.Recipient[0],
|
||||
Recipient: result.Recipient,
|
||||
};
|
||||
};
|
||||
|
||||
@ -7,15 +7,17 @@ import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/pri
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export interface CreateUserOptions {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
signature?: string | null;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
|
||||
export const createUser = async ({ name, email, password, signature, url }: CreateUserOptions) => {
|
||||
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
const userExists = await prisma.user.findFirst({
|
||||
@ -28,6 +30,22 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
if (url) {
|
||||
const urlExists = await prisma.user.findFirst({
|
||||
where: {
|
||||
url,
|
||||
},
|
||||
});
|
||||
|
||||
if (urlExists) {
|
||||
throw new AppError(
|
||||
AppErrorCode.PROFILE_URL_TAKEN,
|
||||
'Profile username is taken',
|
||||
'The profile username is already taken',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
@ -35,6 +53,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
||||
password: hashedPassword,
|
||||
signature,
|
||||
identityProvider: IdentityProvider.DOCUMENSO,
|
||||
url,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
49
packages/lib/server-only/user/update-public-profile.ts
Normal file
49
packages/lib/server-only/user/update-public-profile.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export type UpdatePublicProfileOptions = {
|
||||
userId: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOptions) => {
|
||||
const isUrlTaken = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
id: {
|
||||
not: userId,
|
||||
},
|
||||
url,
|
||||
},
|
||||
});
|
||||
|
||||
if (isUrlTaken) {
|
||||
throw new AppError(
|
||||
AppErrorCode.PROFILE_URL_TAKEN,
|
||||
'Profile username is taken',
|
||||
'The profile username is already taken',
|
||||
);
|
||||
}
|
||||
|
||||
return await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
url,
|
||||
userProfile: {
|
||||
upsert: {
|
||||
create: {
|
||||
bio: '',
|
||||
},
|
||||
update: {
|
||||
bio: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -2,6 +2,7 @@ import type { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { sign } from '../../crypto/sign';
|
||||
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
|
||||
|
||||
export type TriggerWebhookOptions = {
|
||||
event: WebhookTriggerEvents;
|
||||
@ -19,6 +20,12 @@ export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWeb
|
||||
teamId,
|
||||
};
|
||||
|
||||
const registeredWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
|
||||
|
||||
if (registeredWebhooks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = sign(body);
|
||||
|
||||
await Promise.race([
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[profileURL]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "profileURL" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserProfile" (
|
||||
"profileURL" TEXT NOT NULL,
|
||||
"profileBio" TEXT,
|
||||
|
||||
CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("profileURL")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserProfile_profileURL_key" ON "UserProfile"("profileURL");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_profileURL_key" ON "User"("profileURL");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_profileURL_fkey" FOREIGN KEY ("profileURL") REFERENCES "UserProfile"("profileURL") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,37 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `profileURL` on the `User` table. All the data in the column will be lost.
|
||||
- The primary key for the `UserProfile` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to drop the column `profileBio` on the `UserProfile` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `profileURL` on the `UserProfile` table. All the data in the column will be lost.
|
||||
- A unique constraint covering the columns `[url]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `id` to the `UserProfile` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "User" DROP CONSTRAINT "User_profileURL_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "User_profileURL_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "UserProfile_profileURL_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "profileURL",
|
||||
ADD COLUMN "url" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_pkey",
|
||||
DROP COLUMN "profileBio",
|
||||
DROP COLUMN "profileURL",
|
||||
ADD COLUMN "bio" TEXT,
|
||||
ADD COLUMN "id" INTEGER NOT NULL,
|
||||
ADD CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_url_key" ON "User"("url");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserProfile" ADD CONSTRAINT "UserProfile_id_fkey" FOREIGN KEY ("id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -43,7 +43,9 @@ model User {
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorBackupCodes String?
|
||||
url String? @unique
|
||||
|
||||
userProfile UserProfile?
|
||||
VerificationToken VerificationToken[]
|
||||
ApiToken ApiToken[]
|
||||
Template Template[]
|
||||
@ -54,6 +56,13 @@ model User {
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model UserProfile {
|
||||
id Int @id
|
||||
bio String?
|
||||
|
||||
User User? @relation(fields: [id], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum UserSecurityAuditLogType {
|
||||
ACCOUNT_PROFILE_UPDATE
|
||||
ACCOUNT_SSO_LINK
|
||||
|
||||
@ -49,6 +49,7 @@ export const seedDatabase = async () => {
|
||||
email: u.email,
|
||||
password: hashSync(u.password),
|
||||
emailVerified: new Date(),
|
||||
url: u.email,
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
@ -49,6 +49,7 @@ export const seedDatabase = async () => {
|
||||
email: u.email,
|
||||
password: hashSync(u.password),
|
||||
emailVerified: new Date(),
|
||||
url: u.email,
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
@ -23,6 +23,7 @@ export const seedDatabase = async () => {
|
||||
email: TEST_USER.email,
|
||||
password: hashSync(TEST_USER.password),
|
||||
emailVerified: new Date(),
|
||||
url: TEST_USER.email,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -21,6 +21,7 @@ export const seedUser = async ({
|
||||
email,
|
||||
password: hashSync(password),
|
||||
emailVerified: verified ? new Date() : undefined,
|
||||
url: name,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -5,6 +5,6 @@ export type DocumentWithRecipients = Document & {
|
||||
};
|
||||
|
||||
export type DocumentWithRecipient = Document & {
|
||||
Recipient: Recipient;
|
||||
Recipient: Recipient[];
|
||||
documentData: DocumentData;
|
||||
};
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { compareSync } from '@documenso/lib/server-only/auth/hash';
|
||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||
@ -21,14 +23,29 @@ export const authRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const { name, email, password, signature } = input;
|
||||
const { name, email, password, signature, url } = input;
|
||||
|
||||
const user = await createUser({ name, email, password, signature });
|
||||
if (IS_BILLING_ENABLED() && url && url.length < 6) {
|
||||
throw new AppError(
|
||||
AppErrorCode.PREMIUM_PROFILE_URL,
|
||||
'Only subscribers can have a username shorter than 6 characters',
|
||||
);
|
||||
}
|
||||
|
||||
const user = await createUser({ name, email, password, signature, url });
|
||||
|
||||
await sendConfirmationToken({ email: user.email });
|
||||
|
||||
return user;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
|
||||
throw AppError.parseErrorToTRPCError(error);
|
||||
}
|
||||
|
||||
let message =
|
||||
'We were unable to create your account. Please review the information you provided and try again.';
|
||||
|
||||
|
||||
@ -21,6 +21,15 @@ export const ZSignUpMutationSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().min(1, { message: 'A signature is required.' }),
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(1)
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: 'Username can only container alphanumeric characters and dashes.',
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
||||
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
|
||||
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||
@ -8,7 +11,9 @@ import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
||||
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
||||
import { updatePublicProfile } from '@documenso/lib/server-only/user/update-public-profile';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
@ -19,6 +24,7 @@ import {
|
||||
ZRetrieveUserByIdQuerySchema,
|
||||
ZUpdatePasswordMutationSchema,
|
||||
ZUpdateProfileMutationSchema,
|
||||
ZUpdatePublicProfileMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
export const profileRouter = router({
|
||||
@ -74,6 +80,48 @@ export const profileRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
updatePublicProfile: authenticatedProcedure
|
||||
.input(ZUpdatePublicProfileMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { url } = input;
|
||||
|
||||
if (IS_BILLING_ENABLED() && url.length <= 6) {
|
||||
const subscriptions = await getSubscriptionsByUserId({
|
||||
userId: ctx.user.id,
|
||||
}).then((subscriptions) =>
|
||||
subscriptions.filter((s) => s.status === SubscriptionStatus.ACTIVE),
|
||||
);
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
throw new AppError(
|
||||
AppErrorCode.PREMIUM_PROFILE_URL,
|
||||
'Only subscribers can have a username shorter than 6 characters',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await updatePublicProfile({
|
||||
userId: ctx.user.id,
|
||||
url,
|
||||
});
|
||||
|
||||
return { success: true, url: user.url };
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
|
||||
throw AppError.parseErrorToTRPCError(error);
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'We were unable to update your public profile. Please review the information you provided and try again.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
updatePassword: authenticatedProcedure
|
||||
.input(ZUpdatePasswordMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@ -16,6 +16,17 @@ export const ZUpdateProfileMutationSchema = z.object({
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
export const ZUpdatePublicProfileMutationSchema = 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 const ZUpdatePasswordMutationSchema = z.object({
|
||||
currentPassword: ZCurrentPasswordSchema,
|
||||
password: ZPasswordSchema,
|
||||
|
||||
31
packages/ui/icons/verified.tsx
Normal file
31
packages/ui/icons/verified.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
export const VerifiedIcon: LucideIcon = forwardRef(
|
||||
({ size = 24, color = 'currentColor', ...props }, ref) => {
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g id="badge, verified, award">
|
||||
<path
|
||||
id="Icon"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.5457 2.89094C11.5779 1.70302 13.4223 1.70302 14.4545 2.89094L15.2585 3.81628C15.3917 3.96967 15.5947 4.04354 15.7954 4.0117L17.0061 3.81965C18.5603 3.57309 19.9732 4.75869 20.0003 6.33214L20.0214 7.55778C20.0249 7.76096 20.1329 7.94799 20.3071 8.05261L21.358 8.6837C22.7071 9.49389 23.0274 11.3103 22.0368 12.5331L21.2651 13.4855C21.1372 13.6434 21.0997 13.8561 21.1659 14.0482L21.5652 15.2072C22.0779 16.695 21.1557 18.2923 19.6109 18.5922L18.4075 18.8258C18.208 18.8646 18.0426 19.0034 17.9698 19.1931L17.5308 20.3376C16.9672 21.8069 15.234 22.4378 13.8578 21.6745L12.7858 21.08C12.6081 20.9814 12.3921 20.9814 12.2144 21.08L11.1424 21.6745C9.76623 22.4378 8.033 21.8069 7.4694 20.3376L7.03038 19.1931C6.9576 19.0034 6.79216 18.8646 6.59268 18.8258L5.38932 18.5922C3.84448 18.2923 2.92224 16.695 3.43495 15.2072L3.83431 14.0482C3.90052 13.8561 3.86302 13.6434 3.7351 13.4855L2.96343 12.5331C1.97279 11.3103 2.29307 9.49389 3.64218 8.6837L4.69306 8.05261C4.86728 7.94799 4.97526 7.76096 4.97875 7.55778L4.99985 6.33214C5.02694 4.75869 6.43987 3.57309 7.99413 3.81965L9.20481 4.0117C9.40551 4.04354 9.60845 3.96967 9.74173 3.81628L10.5457 2.89094ZM15.7072 11.2071C16.0977 10.8166 16.0977 10.1834 15.7072 9.79289C15.3167 9.40237 14.6835 9.40237 14.293 9.79289L11.5001 12.5858L10.7072 11.7929C10.3167 11.4024 9.68351 11.4024 9.29298 11.7929C8.90246 12.1834 8.90246 12.8166 9.29298 13.2071L10.4394 14.3536C11.0252 14.9393 11.975 14.9393 12.5608 14.3536L15.7072 11.2071Z"
|
||||
fill={color}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
VerifiedIcon.displayName = 'VerifiedIcon';
|
||||
@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border bg-transparent px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'bg-background border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
{
|
||||
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import * as TogglePrimitive from '@radix-ui/react-toggle';
|
||||
import { VariantProps, cva } from 'class-variance-authority';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user