Merge branch 'main' of https://github.com/documenso/documenso into feat/typefully

This commit is contained in:
Adithya Krishna
2024-03-05 14:58:27 +05:30
99 changed files with 2702 additions and 450 deletions

24
.github/actions/cache-build/action.yml vendored Normal file
View File

@ -0,0 +1,24 @@
name: Cache production build binaries
description: 'Cache or restore if necessary'
inputs:
node_version:
required: false
default: v18.x
runs:
using: 'composite'
steps:
- name: Cache production build
uses: actions/cache@v3
id: production-build-cache
with:
path: |
${{ github.workspace }}/apps/web/.next
${{ github.workspace }}/apps/marketing/.next
**/.turbo/**
**/dist/**
key: prod-build-${{ github.run_id }}
restore-keys: prod-build-
- run: npm run build
shell: bash

39
.github/actions/node-install/action.yml vendored Normal file
View File

@ -0,0 +1,39 @@
name: 'Setup node and cache node_modules'
inputs:
node_version:
required: false
default: v18.x
runs:
using: 'composite'
steps:
- name: Set up Node ${{ inputs.node_version }}
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Cache npm
uses: actions/cache@v3
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-
- name: Cache node_modules
uses: actions/cache@v3
id: cache-node-modules
with:
path: |
node_modules
packages/*/node_modules
apps/*/node_modules
key: modules-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
shell: bash
run: |
npm ci --no-audit
npm run prisma:generate
env:
HUSKY: '0'

View File

@ -0,0 +1,19 @@
name: Install playwright binaries
description: 'Install playwright, cache and restore if necessary'
runs:
using: 'composite'
steps:
- name: Cache playwright
id: cache-playwright
uses: actions/cache@v3
with:
path: |
~/.cache/ms-playwright
${{ github.workspace }}/node_modules/playwright
key: playwright-${{ hashFiles('**/package-lock.json') }}
restore-keys: playwright-
- name: Install playwright
if: steps.cache-playwright.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
shell: bash

View File

@ -1,6 +1,7 @@
name: 'Continuous Integration' name: 'Continuous Integration'
on: on:
workflow_call:
push: push:
branches: ['main'] branches: ['main']
pull_request: pull_request:
@ -10,9 +11,6 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
env:
HUSKY: 0
jobs: jobs:
build_app: build_app:
name: Build App name: Build App
@ -23,20 +21,12 @@ jobs:
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Install Node.js - uses: ./.github/actions/node-install
uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Copy env - name: Copy env
run: cp .env.example .env run: cp .env.example .env
- name: Build - uses: ./.github/actions/cache-build
run: npm run build
build_docker: build_docker:
name: Build Docker Image name: Build Docker Image
@ -47,5 +37,31 @@ jobs:
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build Docker Image - name: Build Docker Image
run: ./docker/build.sh uses: docker/build-push-action@v5
with:
push: false
context: .
file: ./docker/Dockerfile
tags: documenso-${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- # Temp fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

29
.github/workflows/clean-cache.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: cleanup caches by a branch
on:
pull_request:
types:
- closed
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge

View File

@ -25,19 +25,12 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install Dependencies
run: npm ci
- name: Copy env - name: Copy env
run: cp .env.example .env run: cp .env.example .env
- name: Build Documenso - uses: ./.github/actions/node-install
run: npm run build
- uses: ./.github/actions/cache-build
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v2

View File

@ -6,29 +6,21 @@ on:
branches: ['main'] branches: ['main']
jobs: jobs:
e2e_tests: e2e_tests:
name: "E2E Tests" name: 'E2E Tests'
timeout-minutes: 60 timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Copy env - name: Copy env
run: cp .env.example .env run: cp .env.example .env
- uses: ./.github/actions/node-install
- name: Start Services - name: Start Services
run: npm run dx:up run: npm run dx:up
- name: Install Playwright Browsers - uses: ./.github/actions/playwright-install
run: npx playwright install --with-deps
- name: Generate Prisma Client
run: npm run prisma:generate -w @documenso/prisma
- name: Create the database - name: Create the database
run: npm run prisma:migrate-dev run: npm run prisma:migrate-dev
@ -36,6 +28,8 @@ jobs:
- name: Seed the database - name: Seed the database
run: npm run prisma:seed run: npm run prisma:seed
- uses: ./.github/actions/cache-build
- name: Run Playwright tests - name: Run Playwright tests
run: npm run ci run: npm run ci
@ -43,7 +37,7 @@ jobs:
if: always() if: always()
with: with:
name: test-results name: test-results
path: "packages/app-tests/**/test-results/*" path: 'packages/app-tests/**/test-results/*'
retention-days: 30 retention-days: 30
env: env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}

View File

@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
To run Documenso locally, you will need To run Documenso locally, you will need
- Node.js - Node.js (v18 or above)
- Postgres SQL Database - Postgres SQL Database
- Docker (optional) - Docker (optional)

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

View File

@ -2,8 +2,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Footer } from '~/components/(marketing)/footer'; import { Footer } from '~/components/(marketing)/footer';
@ -17,6 +21,10 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
const [scrollY, setScrollY] = useState(0); const [scrollY, setScrollY] = useState(0);
const pathname = usePathname(); const pathname = usePathname();
const { getFlag } = useFeatureFlags();
const showProfilesAnnouncementBar = getFlag('marketing_profiles_announcement_bar');
useEffect(() => { useEffect(() => {
const onScroll = () => { const onScroll = () => {
setScrollY(window.scrollY); setScrollY(window.scrollY);
@ -38,6 +46,31 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
'bg-background/50 backdrop-blur-md': scrollY > 5, 'bg-background/50 backdrop-blur-md': scrollY > 5,
})} })}
> >
{showProfilesAnnouncementBar && (
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
<div className="absolute inset-0 -z-[1]">
<Image
src={launchWeekTwoImage}
className="h-full w-full object-cover"
alt="Launch Week 2"
/>
</div>
<div className="text-background text-center text-sm 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" /> <Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div> </div>

View File

@ -27,7 +27,7 @@ export default async function SinglePlayerModeSuccessPage({
return notFound(); 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} />; return <SinglePlayerModeSuccess document={document} signatures={signatures} />;
} }

View File

@ -191,7 +191,7 @@ export const SinglePlayerClient = () => {
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal"> <p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '} Create a{' '}
<Link <Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
target="_blank" target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors" className="hover:text-foreground/80 font-semibold transition-colors"
> >

View File

@ -40,9 +40,9 @@ export const Callout = ({ starCount }: CalloutProps) => {
className="rounded-full bg-transparent backdrop-blur-sm" className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick} onClick={onSignUpClick}
> >
Get the Early Adopters Plan 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"> <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. forever! $30/mo
</span> </span>
</Button> </Button>

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import Image from 'next/image'; import Image from 'next/image';

View File

@ -9,6 +9,7 @@ import Link from 'next/link';
import LogoImage from '@documenso/assets/logo.png'; import LogoImage from '@documenso/assets/logo.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { HamburgerMenu } from './mobile-hamburger'; import { HamburgerMenu } from './mobile-hamburger';
import { MobileNavigation } from './mobile-navigation'; import { MobileNavigation } from './mobile-navigation';
@ -68,12 +69,18 @@ export const Header = ({ className, ...props }: HeaderProps) => {
</Link> </Link>
<Link <Link
href="https://app.documenso.com/signin" href="https://app.documenso.com/signin?utm_source=marketing-header"
target="_blank" target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold" className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
> >
Sign in Sign in
</Link> </Link>
<Button className="rounded-full" size="sm" asChild>
<Link href="https://app.documenso.com/signup?utm_source=marketing-header" target="_blank">
Sign up
</Link>
</Button>
</div> </div>
<HamburgerMenu <HamburgerMenu

View File

@ -3,7 +3,8 @@
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; 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 { usePlausible } from 'next-plausible';
import { LuGithub } from 'react-icons/lu'; import { LuGithub } from 'react-icons/lu';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -113,9 +114,9 @@ export const Hero = ({ className, ...props }: HeroProps) => {
className="rounded-full bg-transparent backdrop-blur-sm" className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick} onClick={onSignUpClick}
> >
Get the Early Adopters Plan 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"> <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. forever! $30/mo
</span> </span>
</Button> </Button>
@ -224,8 +225,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
<span className="bg-primary text-black"> <span className="bg-primary text-black">
(in a non-legally binding, but heartfelt way) (in a non-legally binding, but heartfelt way)
</span>{' '} </span>{' '}
and lock in the early supporter plan for forever, including everything we build this and lock in the community plan for forever, including everything we build this year.
year.
</p> </p>
<div className="flex h-24 items-center"> <div className="flex h-24 items-center">

View File

@ -47,9 +47,13 @@ export const MENU_NAVIGATION_LINKS = [
text: 'Privacy', text: 'Privacy',
}, },
{ {
href: 'https://app.documenso.com/signin', href: 'https://app.documenso.com/signin?utm_source=marketing-header',
text: 'Sign in', text: 'Sign in',
}, },
{
href: 'https://app.documenso.com/signup?utm_source=marketing-header',
text: 'Sign up',
},
]; ];
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => { export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {

View File

@ -83,7 +83,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p> </p>
<Button className="rounded-full text-base" asChild> <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 Signup Now
</Link> </Link>
</Button> </Button>
@ -114,7 +118,10 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p> </p>
<Button className="mt-6 rounded-full text-base" asChild> <Button className="mt-6 rounded-full text-base" asChild>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank"> <Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-community`}
target="_blank"
>
Signup Now Signup Now
</Link> </Link>
</Button> </Button>

View File

@ -55,7 +55,7 @@ export const SinglePlayerModeSuccess = ({
<SigningCard3D <SigningCard3D
className="mt-8" className="mt-8"
name={document.Recipient.name || document.Recipient.email} name={document.Recipient[0].name || document.Recipient[0].email}
signature={signatures.at(0)} signature={signatures.at(0)}
signingCelebrationImage={signingCelebration} signingCelebrationImage={signingCelebration}
/> />
@ -65,7 +65,7 @@ export const SinglePlayerModeSuccess = ({
<div className="grid w-full max-w-sm grid-cols-2 gap-4"> <div className="grid w-full max-w-sm grid-cols-2 gap-4">
<DocumentShareButton <DocumentShareButton
documentId={document.id} documentId={document.id}
token={document.Recipient.token} token={document.Recipient[0].token}
className="flex-1 bg-transparent backdrop-blur-sm" className="flex-1 bg-transparent backdrop-blur-sm"
/> />
@ -86,7 +86,7 @@ export const SinglePlayerModeSuccess = ({
<p className="text-muted-foreground/60 mt-16 text-center text-sm"> <p className="text-muted-foreground/60 mt-16 text-center text-sm">
Create a{' '} Create a{' '}
<Link <Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
target="_blank" target="_blank"
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap" className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
> >

View File

@ -199,7 +199,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
className="bg-foreground/5 col-span-12 flex flex-col rounded-2xl p-6 lg:col-span-5" className="bg-foreground/5 col-span-12 flex flex-col rounded-2xl p-6 lg:col-span-5"
onSubmit={handleSubmit(onFormSubmit)} onSubmit={handleSubmit(onFormSubmit)}
> >
<h3 className="text-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"> <p className="text-muted-foreground mt-2 text-xs">
with Timur Ercan & Lucas Smith from Documenso with Timur Ercan & Lucas Smith from Documenso
</p> </p>
@ -208,7 +208,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<AnimatePresence> <AnimatePresence>
<motion.div key="email"> <motion.div key="email">
<label htmlFor="email" className="text-foreground text-lg font-semibold lg:text-xl"> <label htmlFor="email" className="text-foreground font-medium ">
Whats your email? Whats your email?
</label> </label>
@ -220,7 +220,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="" placeholder="your@example.com"
className="bg-background w-full pr-16" className="bg-background w-full pr-16"
disabled={isSubmitting} disabled={isSubmitting}
onKeyDown={(e) => onKeyDown={(e) =>
@ -265,11 +265,8 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
transform: 'translateX(25%)', transform: 'translateX(25%)',
}} }}
> >
<label <label htmlFor="name" className="text-foreground font-medium ">
htmlFor="name" And your name?
className="text-foreground text-lg font-semibold lg:text-xl"
>
and your name?
</label> </label>
<Controller <Controller

View File

@ -0,0 +1,69 @@
'use client';
import Link from 'next/link';
import { type Document, DocumentStatus } 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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminActionsProps = {
className?: string;
document: Document;
};
export const AdminActions = ({ className, document }: AdminActionsProps) => {
const { toast } = useToast();
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Document resealed',
});
},
onError: () => {
toast({
title: 'Error',
description: 'Failed to reseal document',
variant: 'destructive',
});
},
});
return (
<div className={cn('flex gap-x-4', className)}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
loading={isResealDocumentLoading}
disabled={document.status !== DocumentStatus.COMPLETED}
onClick={() => resealDocument({ id: document.id })}
>
Reseal document
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[40ch]">
Attempts sealing the document again, useful for after a code change has occurred to
resolve an erroneous document.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="outline" asChild>
<Link href={`/admin/users/${document.userId}`}>Go to owner</Link>
</Button>
</div>
);
};

View File

@ -0,0 +1,86 @@
import { DateTime } from 'luxon';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { Badge } from '@documenso/ui/primitives/badge';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
import { AdminActions } from './admin-actions';
import { RecipientItem } from './recipient-item';
type AdminDocumentDetailsPageProps = {
params: {
id: string;
};
};
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
const document = await getEntireDocument({ id: Number(params.id) });
return (
<div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-x-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
<DocumentStatus status={document.status} />
</div>
{document.deletedAt && (
<Badge size="large" variant="destructive">
Deleted
</Badge>
)}
</div>
<div className="text-muted-foreground mt-4 text-sm">
<div>
Created on: <LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
</div>
<div>
Last updated at: <LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
</div>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">Admin Actions</h2>
<AdminActions className="mt-2" document={document} />
<hr className="my-4" />
<h2 className="text-lg font-semibold">Recipients</h2>
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">
{document.Recipient.map((recipient) => (
<AccordionItem
key={recipient.id}
value={recipient.id.toString()}
className="rounded-lg border"
>
<AccordionTrigger className="px-4">
<div className="flex items-center gap-x-4">
<h4 className="font-semibold">{recipient.name}</h4>
<Badge size="small" variant="neutral">
{recipient.email}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="border-t px-4 pt-4">
<RecipientItem recipient={recipient} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
);
}

View File

@ -0,0 +1,182 @@
'use client';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {
type Field,
type Recipient,
type Signature,
SigningStatus,
} from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
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';
const ZAdminUpdateRecipientFormSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
type TAdminUpdateRecipientFormSchema = z.infer<typeof ZAdminUpdateRecipientFormSchema>;
export type RecipientItemProps = {
recipient: Recipient & {
Field: Array<
Field & {
Signature: Signature | null;
}
>;
};
};
export const RecipientItem = ({ recipient }: RecipientItemProps) => {
const { toast } = useToast();
const router = useRouter();
const form = useForm<TAdminUpdateRecipientFormSchema>({
defaultValues: {
name: recipient.name,
email: recipient.email,
},
});
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
try {
await updateRecipient({
id: recipient.id,
name,
email,
});
toast({
title: 'Recipient updated',
description: 'The recipient has been updated successfully',
});
router.refresh();
} catch (error) {
toast({
title: 'Failed to update recipient',
description: error.message,
variant: 'destructive',
});
}
};
return (
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onUpdateRecipientFormSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-4"
disabled={
form.formState.isSubmitting || recipient.signingStatus === SigningStatus.SIGNED
}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button type="submit" loading={form.formState.isSubmitting}>
Update Recipient
</Button>
</div>
</fieldset>
</form>
</Form>
<hr className="my-4" />
<h2 className="mb-4 text-lg font-semibold">Fields</h2>
<DataTable
data={recipient.Field}
columns={[
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: 'Type',
accessorKey: 'type',
cell: ({ row }) => <div>{row.original.type}</div>,
},
{
header: 'Inserted',
accessorKey: 'inserted',
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
},
{
header: 'Value',
accessorKey: 'customText',
cell: ({ row }) => <div>{row.original.customText}</div>,
},
{
header: 'Signature',
accessorKey: 'signature',
cell: ({ row }) => (
<div>
{row.original.Signature?.typedSignature && (
<span>{row.original.Signature.typedSignature}</span>
)}
{row.original.Signature?.signatureImageAsBase64 && (
<img
src={row.original.Signature.signatureImageAsBase64}
alt="Signature"
className="h-12 w-full dark:invert"
/>
)}
</div>
),
},
]}
/>
</div>
);
};

View File

@ -1,125 +0,0 @@
'use client';
import { useTransition } from 'react';
import Link from 'next/link';
import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Document, User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
export type DocumentsDataTableProps = {
results: FindResultSet<
Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
}
>;
};
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
return (
<div className="relative">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
accessorKey: 'title',
cell: ({ row }) => {
return (
<div className="block max-w-[5rem] truncate font-medium md:max-w-[10rem]">
{row.original.title}
</div>
);
},
},
{
header: 'Owner',
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span>
<span>{row.original.User.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
]}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@ -0,0 +1,150 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
// export type AdminDocumentResultsProps = {};
export const AdminDocumentResults = () => {
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [term, setTerm] = useState(() => searchParams?.get?.('term') ?? '');
const debouncedTerm = useDebouncedValue(term, 500);
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const { data: findDocumentsData, isLoading: isFindDocumentsLoading } =
trpc.admin.findDocuments.useQuery(
{
term: debouncedTerm,
page: page || 1,
perPage: perPage || 20,
},
{
keepPreviousData: true,
},
);
const onPaginationChange = (newPage: number, newPerPage: number) => {
updateSearchParams({
page: newPage,
perPage: newPerPage,
});
};
return (
<div>
<Input
type="search"
placeholder="Search by document title"
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
<div className="relative mt-4">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
accessorKey: 'title',
cell: ({ row }) => {
return (
<Link
href={`/admin/documents/${row.original.id}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
>
{row.original.title}
</Link>
);
},
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
{
header: 'Owner',
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span>
<span>{row.original.User.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
]}
data={findDocumentsData?.data ?? []}
perPage={findDocumentsData?.perPage ?? 20}
currentPage={findDocumentsData?.currentPage ?? 1}
totalPages={findDocumentsData?.totalPages ?? 1}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isFindDocumentsLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
</div>
);
};

View File

@ -1,28 +1,12 @@
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; import { AdminDocumentResults } from './document-results';
import { DocumentsDataTable } from './data-table';
export type DocumentsPageProps = {
searchParams?: {
page?: string;
perPage?: string;
};
};
export default async function Documents({ searchParams = {} }: DocumentsPageProps) {
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const results = await findDocuments({
page,
perPage,
});
export default function AdminDocumentsPage() {
return ( return (
<div> <div>
<h2 className="text-4xl font-semibold">Manage documents</h2> <h2 className="text-4xl font-semibold">Manage documents</h2>
<div className="mt-8"> <div className="mt-8">
<DocumentsDataTable results={results} /> <AdminDocumentResults />
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteUserDialogProps = {
className?: string;
user: User;
};
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const [email, setEmail] = useState('');
const { mutateAsync: deleteUser, isLoading: isDeletingUser } =
trpc.admin.deleteUser.useMutation();
const onDeleteAccount = async () => {
try {
await deleteUser({
id: user.id,
email,
});
toast({
title: 'Account deleted',
description: 'The account has been deleted successfully.',
duration: 5000,
});
router.push('/admin/users');
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
err.message ??
'We encountered an unknown error while attempting to delete your account. Please try again later.',
});
}
}
};
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
variant="neutral"
>
<div>
<AlertTitle>Delete Account</AlertTitle>
<AlertDescription className="mr-2">
Delete the users account and all its contents. This action is irreversible and will
cancel their subscription, so proceed with caution.
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Account</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>
To confirm, please enter the accounts email address <br />({user.email}).
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<DialogFooter>
<Button
onClick={onDeleteAccount}
loading={isDeletingUser}
variant="destructive"
disabled={email !== user.email}
>
{isDeletingUser ? 'Deleting account...' : 'Delete Account'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
);
};

View File

@ -7,7 +7,7 @@ import { useForm } from 'react-hook-form';
import type { z } from 'zod'; import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Form, Form,
@ -20,9 +20,10 @@ import {
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { DeleteUserDialog } from './delete-user-dialog';
import { MultiSelectRoleCombobox } from './multiselect-role-combobox'; import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>; type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
@ -137,6 +138,10 @@ export default function UserPage({ params }: { params: { id: number } }) {
</fieldset> </fieldset>
</form> </form>
</Form> </Form>
<hr className="my-4" />
{user && <DeleteUserDialog user={user} />}
</div> </div>
); );
} }

View File

@ -19,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const [{ users, totalPages }, individualPrices] = await Promise.all([ const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage), search(searchString, page, perPage),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY), getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY).catch(() => []),
]); ]);
const individualPriceIds = individualPrices.map((price) => price.id); const individualPriceIds = individualPrices.map((price) => price.id);

View File

@ -1,7 +1,10 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { DocumentsPageViewProps } from './documents-page-view'; import type { DocumentsPageViewProps } from './documents-page-view';
import { DocumentsPageView } from './documents-page-view'; import { DocumentsPageView } from './documents-page-view';
import { UpcomingProfileClaimTeaser } from './upcoming-profile-claim-teaser';
export type DocumentsPageProps = { export type DocumentsPageProps = {
searchParams?: DocumentsPageViewProps['searchParams']; searchParams?: DocumentsPageViewProps['searchParams'];
@ -11,6 +14,12 @@ export const metadata: Metadata = {
title: 'Documents', title: 'Documents',
}; };
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) { export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
return <DocumentsPageView searchParams={searchParams} />; const { user } = await getRequiredServerComponentSession();
return (
<>
<UpcomingProfileClaimTeaser user={user} />
<DocumentsPageView searchParams={searchParams} />
</>
);
} }

View File

@ -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}
/>
);
};

View File

@ -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} />
</>
);
};

View File

@ -5,6 +5,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { ProfileForm } from '~/components/forms/profile'; import { ProfileForm } from '~/components/forms/profile';
import { ClaimProfileAlertDialog } from './claim-profile-alert-dialog';
import { DeleteAccountDialog } from './delete-account-dialog'; import { DeleteAccountDialog } from './delete-account-dialog';
export const metadata: Metadata = { export const metadata: Metadata = {
@ -18,9 +19,13 @@ export default async function ProfileSettingsPage() {
<div> <div>
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." /> <SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
<ProfileForm className="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> </div>
); );
} }

View File

@ -1,5 +1,8 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import ActivityPageBackButton from '../../../../../components/(dashboard)/settings/layout/activity-back';
import { UserSecurityActivityDataTable } from './user-security-activity-data-table'; import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
export const metadata: Metadata = { export const metadata: Metadata = {
@ -9,11 +12,14 @@ export const metadata: Metadata = {
export default function SettingsSecurityActivityPage() { export default function SettingsSecurityActivityPage() {
return ( return (
<div> <div>
<h3 className="text-2xl font-semibold">Security activity</h3> <SettingsHeader
title="Security activity"
<p className="text-muted-foreground mt-2 text-sm"> subtitle="View all recent security activity related to your account."
View all recent security activity related to your account. >
</p> <div>
<ActivityPageBackButton />
</div>
</SettingsHeader>
<hr className="my-4" /> <hr className="my-4" />

View File

@ -9,17 +9,19 @@ export const metadata: Metadata = {
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
return ( return (
<div> <div className="w-screen max-w-lg px-4">
<h1 className="text-4xl font-semibold">Email sent!</h1> <div className="w-full">
<h1 className="text-4xl font-semibold">Email sent!</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm"> <p className="text-muted-foreground mb-4 mt-2 text-sm">
A password reset email has been sent, if you have an account you should see it in your inbox A password reset email has been sent, if you have an account you should see it in your
shortly. inbox shortly.
</p> </p>
<Button asChild> <Button asChild>
<Link href="/signin">Return to sign in</Link> <Link href="/signin">Return to sign in</Link>
</Button> </Button>
</div>
</div> </div>
); );
} }

View File

@ -9,22 +9,24 @@ export const metadata: Metadata = {
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
return ( return (
<div> <div className="w-screen max-w-lg px-4">
<h1 className="text-4xl font-semibold">Forgot your password?</h1> <div className="w-full">
<h1 className="text-3xl font-semibold">Forgot your password?</h1>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
No worries, it happens! Enter your email and we'll email you a special link to reset your No worries, it happens! Enter your email and we'll email you a special link to reset your
password. password.
</p> </p>
<ForgotPasswordForm className="mt-4" /> <ForgotPasswordForm className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm"> <p className="text-muted-foreground mt-6 text-center text-sm">
Remembered your password?{' '} Remembered your password?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70"> <Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign In Sign In
</Link> </Link>
</p> </p>
</div>
</div> </div>
); );
} }

View File

@ -10,9 +10,9 @@ type UnauthenticatedLayoutProps = {
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) { export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
return ( 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"> <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 className="relative flex w-full max-w-md items-center gap-x-24"> <div>
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50"> <div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
<Image <Image
src={backgroundPattern} src={backgroundPattern}
alt="background pattern" alt="background pattern"
@ -20,7 +20,7 @@ export default function UnauthenticatedLayout({ children }: UnauthenticatedLayou
/> />
</div> </div>
<div className="w-full">{children}</div> <div className="relative w-full">{children}</div>
</div> </div>
</main> </main>
); );

View File

@ -19,19 +19,21 @@ export default async function ResetPasswordPage({ params: { token } }: ResetPass
} }
return ( return (
<div className="w-full"> <div className="w-screen max-w-lg px-4">
<h1 className="text-4xl font-semibold">Reset Password</h1> <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"> <p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '} Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70"> <Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up Sign up
</Link> </Link>
</p> </p>
</div>
</div> </div>
); );
} }

View File

@ -9,17 +9,19 @@ export const metadata: Metadata = {
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
return ( return (
<div> <div className="w-screen max-w-lg px-4">
<h1 className="text-4xl font-semibold">Unable to reset password</h1> <div className="w-full">
<h1 className="text-3xl font-semibold">Unable to reset password</h1>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
The token you have used to reset your password is either expired or it never existed. If you The token you have used to reset your password is either expired or it never existed. If
have still forgotten your password, please request a new reset link. you have still forgotten your password, please request a new reset link.
</p> </p>
<Button className="mt-4" asChild> <Button className="mt-4" asChild>
<Link href="/signin">Return to sign in</Link> <Link href="/signin">Return to sign in</Link>
</Button> </Button>
</div>
</div> </div>
); );
} }

View File

@ -30,36 +30,27 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
} }
return ( return (
<div> <div className="w-screen max-w-lg px-4">
<h1 className="text-4xl font-semibold">Sign in to your account</h1> <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"> <p className="text-muted-foreground mt-2 text-sm">
Welcome back, we are lucky to have you. Welcome back, we are lucky to have you.
</p>
<SignInForm
className="mt-4"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p> </p>
)}
<p className="mt-2.5 text-center"> <hr className="-mx-6 my-4" />
<Link
href="/forgot-password" <SignInForm initialEmail={email || undefined} isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
> {NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
Forgot your password? <p className="text-muted-foreground mt-6 text-center text-sm">
</Link> Don't have an account?{' '}
</p> <Link href="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
Sign up
</Link>
</p>
)}
</div>
</div> </div>
); );
} }

View File

@ -1,5 +1,4 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env'; import { env } from 'next-runtime-env';
@ -7,7 +6,7 @@ import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignUpForm } from '~/components/forms/signup'; import { SignUpFormV2 } from '~/components/forms/v2/signup';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Sign Up', title: 'Sign Up',
@ -34,26 +33,10 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
} }
return ( return (
<div> <SignUpFormV2
<h1 className="text-4xl font-semibold">Create a new account</h1> className="w-screen max-w-screen-2xl px-4 md:px-16"
initialEmail={email || undefined}
<p className="text-muted-foreground/60 mt-2 text-sm"> isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
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>
); );
} }

View File

@ -29,16 +29,18 @@ export default async function AcceptInvitationPage({
if (!teamMemberInvite) { if (!teamMemberInvite) {
return ( return (
<div> <div className="w-screen max-w-lg px-4">
<h1 className="text-4xl font-semibold">Invalid token</h1> <div className="w-full">
<h1 className="text-4xl font-semibold">Invalid token</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm"> <p className="text-muted-foreground mb-4 mt-2 text-sm">
This token is invalid or has expired. Please contact your team for a new invitation. This token is invalid or has expired. Please contact your team for a new invitation.
</p> </p>
<Button asChild> <Button asChild>
<Link href="/">Return</Link> <Link href="/">Return</Link>
</Button> </Button>
</div>
</div> </div>
); );
} }

View File

@ -22,16 +22,18 @@ export default async function VerifyTeamEmailPage({ params: { token } }: VerifyT
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) { if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
return ( return (
<div> <div className="w-screen max-w-lg px-4">
<h1 className="text-4xl font-semibold">Invalid link</h1> <div className="w-full">
<h1 className="text-4xl font-semibold">Invalid link</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm"> <p className="text-muted-foreground mb-4 mt-2 text-sm">
This link is invalid or has expired. Please contact your team to resend a verification. This link is invalid or has expired. Please contact your team to resend a verification.
</p> </p>
<Button asChild> <Button asChild>
<Link href="/">Return</Link> <Link href="/">Return</Link>
</Button> </Button>
</div>
</div> </div>
); );
} }

View File

@ -25,17 +25,19 @@ export default async function VerifyTeamTransferPage({
if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) { if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
return ( return (
<div> <div className="w-screen max-w-lg px-4">
<h1 className="text-4xl font-semibold">Invalid link</h1> <div className="w-full">
<h1 className="text-4xl font-semibold">Invalid link</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm"> <p className="text-muted-foreground mb-4 mt-2 text-sm">
This link is invalid or has expired. Please contact your team to resend a transfer This link is invalid or has expired. Please contact your team to resend a transfer
request. request.
</p> </p>
<Button asChild> <Button asChild>
<Link href="/">Return</Link> <Link href="/">Return</Link>
</Button> </Button>
</div>
</div> </div>
); );
} }

View File

@ -4,23 +4,25 @@ import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-
export default function UnverifiedAccount() { export default function UnverifiedAccount() {
return ( return (
<div className="flex w-full items-start"> <div className="w-screen max-w-lg px-4">
<div className="mr-4 mt-1 hidden md:block"> <div className="flex items-start">
<Mails className="text-primary h-10 w-10" strokeWidth={2} /> <div className="mr-4 mt-1 hidden md:block">
</div> <Mails className="text-primary h-10 w-10" strokeWidth={2} />
<div className=""> </div>
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2> <div className="">
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
<p className="text-muted-foreground mt-4"> <p className="text-muted-foreground mt-4">
To gain access to your account, please confirm your email address by clicking on the To gain access to your account, please confirm your email address by clicking on the
confirmation link from your inbox. confirmation link from your inbox.
</p> </p>
<p className="text-muted-foreground mt-4"> <p className="text-muted-foreground mt-4">
If you don't find the confirmation link in your inbox, you can request a new one below. If you don't find the confirmation link in your inbox, you can request a new one below.
</p> </p>
<SendConfirmationEmailForm /> <SendConfirmationEmailForm />
</div>
</div> </div>
</div> </div>
); );

View File

@ -14,15 +14,17 @@ export type PageProps = {
export default async function VerifyEmailPage({ params: { token } }: PageProps) { export default async function VerifyEmailPage({ params: { token } }: PageProps) {
if (!token) { if (!token) {
return ( return (
<div className="w-full"> <div className="w-screen max-w-lg px-4">
<div className="mb-4 text-red-300"> <div className="w-full">
<XOctagon /> <div className="mb-4 text-red-300">
</div> <XOctagon />
</div>
<h2 className="text-4xl font-semibold">No token provided</h2> <h2 className="text-4xl font-semibold">No token provided</h2>
<p className="text-muted-foreground mt-2 text-base"> <p className="text-muted-foreground mt-2 text-base">
It seems that there is no token provided. Please check your email and try again. It seems that there is no token provided. Please check your email and try again.
</p> </p>
</div>
</div> </div>
); );
} }
@ -31,22 +33,24 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
if (verified === null) { if (verified === null) {
return ( return (
<div className="flex w-full items-start"> <div className="w-screen max-w-lg px-4">
<div className="mr-4 mt-1 hidden md:block"> <div className="flex w-full items-start">
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} /> <div className="mr-4 mt-1 hidden md:block">
</div> <AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
</div>
<div> <div>
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2> <h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
<p className="text-muted-foreground mt-4"> <p className="text-muted-foreground mt-4">
We were unable to verify your email. If your email is not verified already, please try We were unable to verify your email. If your email is not verified already, please try
again. again.
</p> </p>
<Button className="mt-4" asChild> <Button className="mt-4" asChild>
<Link href="/">Go back home</Link> <Link href="/">Go back home</Link>
</Button> </Button>
</div>
</div> </div>
</div> </div>
); );
@ -54,17 +58,41 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
if (!verified) { if (!verified) {
return ( return (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
<p className="text-muted-foreground mt-4">
It seems that the provided token has expired. We've just sent you another token,
please check your email and try again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start"> <div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block"> <div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} /> <CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div> </div>
<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"> <p className="text-muted-foreground mt-4">
It seems that the provided token has expired. We've just sent you another token, please Your email has been successfully confirmed! You can now use all features of Documenso.
check your email and try again.
</p> </p>
<Button className="mt-4" asChild> <Button className="mt-4" asChild>
@ -72,26 +100,6 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
</Button> </Button>
</div> </div>
</div> </div>
);
}
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
<p className="text-muted-foreground mt-4">
Your email has been successfully confirmed! You can now use all features of Documenso.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div> </div>
); );
} }

View File

@ -11,22 +11,26 @@ export const metadata: Metadata = {
export default function EmailVerificationWithoutTokenPage() { export default function EmailVerificationWithoutTokenPage() {
return ( return (
<div className="flex w-full items-start"> <div className="w-screen max-w-lg px-4">
<div className="mr-4 mt-1 hidden md:block"> <div className="flex w-full items-start">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} /> <div className="mr-4 mt-1 hidden md:block">
</div> <XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div> <div>
<h2 className="text-2xl font-bold md:text-4xl">Uh oh! Looks like you're missing a token</h2> <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"> <p className="text-muted-foreground mt-4">
It seems that there is no token provided, if you are trying to verify your email please It seems that there is no token provided, if you are trying to verify your email please
follow the link in your email. follow the link in your email.
</p> </p>
<Button className="mt-4" asChild> <Button className="mt-4" asChild>
<Link href="/">Go back home</Link> <Link href="/">Go back home</Link>
</Button> </Button>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,3 +1,11 @@
'use client'; '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 />;
}

View File

@ -57,7 +57,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
key={href} key={href}
href={`${rootHref}${href}`} href={`${rootHref}${href}`}
className={cn( className={cn(
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2', 'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{ {
'text-foreground dark:text-muted-foreground': pathname?.startsWith( 'text-foreground dark:text-muted-foreground': pathname?.startsWith(
`${rootHref}${href}`, `${rootHref}${href}`,

View File

@ -86,7 +86,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
> >
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8"> <div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<Link <Link
href={getRootHref(params)} href={`${getRootHref(params, { returnEmptyRootString: true })}/documents`}
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline" className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
> >
<Logo className="h-6 w-auto" /> <Logo className="h-6 w-auto" />

View File

@ -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>
);
}

View File

@ -1,15 +1,18 @@
import React from 'react'; import React from 'react';
import { cn } from '@documenso/ui/lib/utils';
export type SettingsHeaderProps = { export type SettingsHeaderProps = {
title: string; title: string;
subtitle: string; subtitle: string;
children?: React.ReactNode; children?: React.ReactNode;
className?: string;
}; };
export const SettingsHeader = ({ children, title, subtitle }: SettingsHeaderProps) => { export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
return ( return (
<> <>
<div className="flex flex-row items-center justify-between"> <div className={cn('flex flex-row items-center justify-between', className)}>
<div> <div>
<h3 className="text-lg font-medium">{title}</h3> <h3 className="text-lg font-medium">{title}</h3>

View 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>
);
};

View File

@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@ -195,9 +196,11 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input type="email" {...field} /> <Input type="email" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -209,9 +212,19 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<FormControl> <FormControl>
<PasswordInput {...field} /> <PasswordInput {...field} />
</FormControl> </FormControl>
<p className="mt-2 text-right">
<Link
href="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
>
Forgot your password?
</Link>
</p>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View 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>
);
};

View 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>
);
};

View 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 Im 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>
);
};

View File

@ -32,6 +32,7 @@ export function PostHogPageview() {
// Do nothing. // Do nothing.
}); });
}, },
custom_campaign_params: ['src'],
}); });
} }

View File

@ -15,7 +15,7 @@
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev", "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev",
"dx:up": "docker compose -f docker/compose-services.yml up -d", "dx:up": "docker compose -f docker/compose-services.yml up -d",
"dx:down": "docker compose -f docker/compose-services.yml down", "dx:down": "docker compose -f docker/compose-services.yml down",
"ci": "turbo run build test:e2e", "ci": "turbo run test:e2e",
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma", "prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma", "prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma", "prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",

View File

@ -8,3 +8,5 @@ import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export const OpenApiDocsPage = () => { export const OpenApiDocsPage = () => {
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />; return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
}; };
export default OpenApiDocsPage;

View File

@ -34,6 +34,7 @@ export const manualLogin = async ({
}; };
export const manualSignout = async ({ page }: ManualLoginOptions) => { export const manualSignout = async ({ page }: ManualLoginOptions) => {
await page.waitForTimeout(1000);
await page.getByTestId('menu-switcher').click(); await page.getByTestId('menu-switcher').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click(); await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`); await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);

View File

@ -29,7 +29,10 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
await page.mouse.up(); 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'); await page.waitForURL('/unverified-account');

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -1,5 +1,5 @@
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema'; import type { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
import { useCopyToClipboard } from './use-copy-to-clipboard'; import { useCopyToClipboard } from './use-copy-to-clipboard';

View File

@ -25,6 +25,7 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_teams: true, app_teams: true,
app_document_page_view_history_sheet: false, app_document_page_view_history_sheet: false,
marketing_header_single_player_mode: false, marketing_header_single_player_mode: false,
marketing_profiles_announcement_bar: true,
} as const; } as const;
/** /**

View File

@ -18,6 +18,8 @@ export enum AppErrorCode {
'RETRY_EXCEPTION' = 'RetryException', 'RETRY_EXCEPTION' = 'RetryException',
'SCHEMA_FAILED' = 'SchemaFailed', 'SCHEMA_FAILED' = 'SchemaFailed',
'TOO_MANY_REQUESTS' = 'TooManyRequests', 'TOO_MANY_REQUESTS' = 'TooManyRequests',
'PROFILE_URL_TAKEN' = 'ProfileUrlTaken',
'PREMIUM_PROFILE_URL' = 'PremiumProfileUrl',
} }
const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = { const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
@ -32,6 +34,8 @@ const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
[AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR', [AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR', [AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS', [AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS',
[AppErrorCode.PROFILE_URL_TAKEN]: 'BAD_REQUEST',
[AppErrorCode.PREMIUM_PROFILE_URL]: 'BAD_REQUEST',
}; };
export const ZAppErrorJsonSchema = z.object({ export const ZAppErrorJsonSchema = z.object({

View File

@ -0,0 +1,26 @@
import { prisma } from '@documenso/prisma';
export type GetEntireDocumentOptions = {
id: number;
};
export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
const document = await prisma.document.findFirstOrThrow({
where: {
id,
},
include: {
Recipient: {
include: {
Field: {
include: {
Signature: true,
},
},
},
},
},
});
return document;
};

View File

@ -0,0 +1,30 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type UpdateRecipientOptions = {
id: number;
name: string | undefined;
email: string | undefined;
};
export const updateRecipient = async ({ id, name, email }: UpdateRecipientOptions) => {
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
id,
},
});
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error('Cannot update a recipient that has already signed.');
}
return await prisma.recipient.update({
where: {
id,
},
data: {
name,
email,
},
});
};

View File

@ -70,6 +70,6 @@ export const getDocumentAndRecipientByToken = async ({
return { return {
...result, ...result,
Recipient: result.Recipient[0], Recipient: result.Recipient,
}; };
}; };

View File

@ -2,7 +2,7 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import path from 'node:path'; import path from 'node:path';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument, PDFSignature, rectangle } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -22,12 +22,14 @@ import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = { export type SealDocumentOptions = {
documentId: number; documentId: number;
sendEmail?: boolean; sendEmail?: boolean;
isResealing?: boolean;
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
}; };
export const sealDocument = async ({ export const sealDocument = async ({
documentId, documentId,
sendEmail = true, sendEmail = true,
isResealing = false,
requestMetadata, requestMetadata,
}: SealDocumentOptions) => { }: SealDocumentOptions) => {
'use server'; 'use server';
@ -78,11 +80,43 @@ export const sealDocument = async ({
throw new Error(`Document ${document.id} has unsigned fields`); throw new Error(`Document ${document.id} has unsigned fields`);
} }
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
documentData.data = documentData.initialData;
}
// !: Need to write the fields onto the document as a hard copy // !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData); const pdfData = await getFile(documentData);
const doc = await PDFDocument.load(pdfData); const doc = await PDFDocument.load(pdfData);
const form = doc.getForm();
// Remove old signatures
for (const field of form.getFields()) {
if (field instanceof PDFSignature) {
field.acroField.getWidgets().forEach((widget) => {
widget.ensureAP();
try {
widget.getNormalAppearance();
} catch (e) {
const { context } = widget.dict;
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
const streamRef = context.register(xobj);
widget.setNormalAppearance(streamRef);
}
});
}
}
// Flatten the form to stop annotation layers from appearing above documenso fields
form.flatten();
for (const field of fields) { for (const field of fields) {
await insertFieldInPDF(doc, field); await insertFieldInPDF(doc, field);
} }
@ -134,7 +168,7 @@ export const sealDocument = async ({
}); });
}); });
if (sendEmail) { if (sendEmail && !isResealing) {
await sendCompletedEmail({ documentId, requestMetadata }); await sendCompletedEmail({ documentId, requestMetadata });
} }

View File

@ -7,15 +7,17 @@ import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/pri
import { IS_BILLING_ENABLED } from '../../constants/app'; import { IS_BILLING_ENABLED } from '../../constants/app';
import { SALT_ROUNDS } from '../../constants/auth'; import { SALT_ROUNDS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface CreateUserOptions { export interface CreateUserOptions {
name: string; name: string;
email: string; email: string;
password: string; password: string;
signature?: string | null; 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 hashedPassword = await hash(password, SALT_ROUNDS);
const userExists = await prisma.user.findFirst({ 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'); 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({ const user = await prisma.user.create({
data: { data: {
name, name,
@ -35,6 +53,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
password: hashedPassword, password: hashedPassword,
signature, signature,
identityProvider: IdentityProvider.DOCUMENSO, identityProvider: IdentityProvider.DOCUMENSO,
url,
}, },
}); });

View File

@ -4,20 +4,18 @@ import { DocumentStatus } from '@documenso/prisma/client';
import { deletedAccountServiceAccount } from './service-accounts/deleted-account'; import { deletedAccountServiceAccount } from './service-accounts/deleted-account';
export type DeleteUserOptions = { export type DeleteUserOptions = {
email: string; id: number;
}; };
export const deleteUser = async ({ email }: DeleteUserOptions) => { export const deleteUser = async ({ id }: DeleteUserOptions) => {
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
email: { id,
contains: email,
},
}, },
}); });
if (!user) { if (!user) {
throw new Error(`User with email ${email} not found`); throw new Error(`User with ID ${id} not found`);
} }
const serviceAccount = await deletedAccountServiceAccount(); const serviceAccount = await deletedAccountServiceAccount();

View 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: '',
},
},
},
},
});
};

View File

@ -2,6 +2,7 @@ import type { WebhookTriggerEvents } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { sign } from '../../crypto/sign'; import { sign } from '../../crypto/sign';
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
export type TriggerWebhookOptions = { export type TriggerWebhookOptions = {
event: WebhookTriggerEvents; event: WebhookTriggerEvents;
@ -19,6 +20,12 @@ export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWeb
teamId, teamId,
}; };
const registeredWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
if (registeredWebhooks.length === 0) {
return;
}
const signature = sign(body); const signature = sign(body);
await Promise.race([ await Promise.race([

View File

@ -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;

View File

@ -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;

View File

@ -43,7 +43,9 @@ model User {
twoFactorSecret String? twoFactorSecret String?
twoFactorEnabled Boolean @default(false) twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String? twoFactorBackupCodes String?
url String? @unique
userProfile UserProfile?
VerificationToken VerificationToken[] VerificationToken VerificationToken[]
ApiToken ApiToken[] ApiToken ApiToken[]
Template Template[] Template Template[]
@ -54,6 +56,13 @@ model User {
@@index([email]) @@index([email])
} }
model UserProfile {
id Int @id
bio String?
User User? @relation(fields: [id], references: [id], onDelete: Cascade)
}
enum UserSecurityAuditLogType { enum UserSecurityAuditLogType {
ACCOUNT_PROFILE_UPDATE ACCOUNT_PROFILE_UPDATE
ACCOUNT_SSO_LINK ACCOUNT_SSO_LINK

View File

@ -49,6 +49,7 @@ export const seedDatabase = async () => {
email: u.email, email: u.email,
password: hashSync(u.password), password: hashSync(u.password),
emailVerified: new Date(), emailVerified: new Date(),
url: u.email,
}, },
}), }),
), ),

View File

@ -49,6 +49,7 @@ export const seedDatabase = async () => {
email: u.email, email: u.email,
password: hashSync(u.password), password: hashSync(u.password),
emailVerified: new Date(), emailVerified: new Date(),
url: u.email,
}, },
}), }),
), ),

View File

@ -23,6 +23,7 @@ export const seedDatabase = async () => {
email: TEST_USER.email, email: TEST_USER.email,
password: hashSync(TEST_USER.password), password: hashSync(TEST_USER.password),
emailVerified: new Date(), emailVerified: new Date(),
url: TEST_USER.email,
}, },
}); });
}; };

View File

@ -21,6 +21,7 @@ export const seedUser = async ({
email, email,
password: hashSync(password), password: hashSync(password),
emailVerified: verified ? new Date() : undefined, emailVerified: verified ? new Date() : undefined,
url: name,
}, },
}); });
}; };

View File

@ -5,6 +5,6 @@ export type DocumentWithRecipients = Document & {
}; };
export type DocumentWithRecipient = Document & { export type DocumentWithRecipient = Document & {
Recipient: Recipient; Recipient: Recipient[];
documentData: DocumentData; documentData: DocumentData;
}; };

View File

@ -1,5 +1,13 @@
import signer from 'node-signpdf'; import signer from 'node-signpdf';
import { PDFArray, PDFDocument, PDFHexString, PDFName, PDFNumber, PDFString } from 'pdf-lib'; import {
PDFArray,
PDFDocument,
PDFHexString,
PDFName,
PDFNumber,
PDFString,
rectangle,
} from 'pdf-lib';
export type AddSigningPlaceholderOptions = { export type AddSigningPlaceholderOptions = {
pdf: Buffer; pdf: Buffer;
@ -39,6 +47,12 @@ export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOption
P: pages[0].ref, P: pages[0].ref,
}); });
const xobj = widget.context.formXObject([rectangle(0, 0, 0, 0)]);
const streamRef = widget.context.register(xobj);
widget.set(PDFName.of('AP'), widget.context.obj({ N: streamRef }));
const widgetRef = doc.context.register(widget); const widgetRef = doc.context.register(widget);
let widgets = pages[0].node.get(PDFName.of('Annots')); let widgets = pages[0].node.get(PDFName.of('Annots'));

View File

@ -1,14 +1,39 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient';
import { updateUser } from '@documenso/lib/server-only/admin/update-user'; import { updateUser } from '@documenso/lib/server-only/admin/update-user';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting'; import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { adminProcedure, router } from '../trpc'; import { adminProcedure, router } from '../trpc';
import { ZUpdateProfileMutationByAdminSchema, ZUpdateSiteSettingMutationSchema } from './schema'; import {
ZAdminDeleteUserMutationSchema,
ZAdminFindDocumentsQuerySchema,
ZAdminResealDocumentMutationSchema,
ZAdminUpdateProfileMutationSchema,
ZAdminUpdateRecipientMutationSchema,
ZAdminUpdateSiteSettingMutationSchema,
} from './schema';
export const adminRouter = router({ export const adminRouter = router({
findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => {
const { term, page, perPage } = input;
try {
return await findDocuments({ term, page, perPage });
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to retrieve the documents. Please try again.',
});
}
}),
updateUser: adminProcedure updateUser: adminProcedure
.input(ZUpdateProfileMutationByAdminSchema) .input(ZAdminUpdateProfileMutationSchema)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { id, name, email, roles } = input; const { id, name, email, roles } = input;
@ -22,8 +47,23 @@ export const adminRouter = router({
} }
}), }),
updateRecipient: adminProcedure
.input(ZAdminUpdateRecipientMutationSchema)
.mutation(async ({ input }) => {
const { id, name, email } = input;
try {
return await updateRecipient({ id, name, email });
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to update the recipient provided.',
});
}
}),
updateSiteSetting: adminProcedure updateSiteSetting: adminProcedure
.input(ZUpdateSiteSettingMutationSchema) .input(ZAdminUpdateSiteSettingMutationSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { try {
const { id, enabled, data } = input; const { id, enabled, data } = input;
@ -41,4 +81,41 @@ export const adminRouter = router({
}); });
} }
}), }),
resealDocument: adminProcedure
.input(ZAdminResealDocumentMutationSchema)
.mutation(async ({ input }) => {
const { id } = input;
try {
return await sealDocument({ documentId: id, isResealing: true });
} catch (err) {
console.log('resealDocument error', err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to reseal the document provided.',
});
}
}),
deleteUser: adminProcedure.input(ZAdminDeleteUserMutationSchema).mutation(async ({ input }) => {
const { id, email } = input;
try {
const user = await getUserById({ id });
if (user.email !== email) {
throw new Error('Email does not match');
}
return await deleteUser({ id });
} catch (err) {
console.log(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to delete the specified account. Please try again.',
});
}
}),
}); });

View File

@ -3,17 +3,48 @@ import z from 'zod';
import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema'; import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema';
export const ZUpdateProfileMutationByAdminSchema = z.object({ export const ZAdminFindDocumentsQuerySchema = z.object({
term: z.string().optional(),
page: z.number().optional().default(1),
perPage: z.number().optional().default(20),
});
export type TAdminFindDocumentsQuerySchema = z.infer<typeof ZAdminFindDocumentsQuerySchema>;
export const ZAdminUpdateProfileMutationSchema = z.object({
id: z.number().min(1), id: z.number().min(1),
name: z.string().nullish(), name: z.string().nullish(),
email: z.string().email().optional(), email: z.string().email().optional(),
roles: z.array(z.nativeEnum(Role)).optional(), roles: z.array(z.nativeEnum(Role)).optional(),
}); });
export type TUpdateProfileMutationByAdminSchema = z.infer< export type TAdminUpdateProfileMutationSchema = z.infer<typeof ZAdminUpdateProfileMutationSchema>;
typeof ZUpdateProfileMutationByAdminSchema
export const ZAdminUpdateRecipientMutationSchema = z.object({
id: z.number().min(1),
name: z.string().optional(),
email: z.string().email().optional(),
});
export type TAdminUpdateRecipientMutationSchema = z.infer<
typeof ZAdminUpdateRecipientMutationSchema
>; >;
export const ZUpdateSiteSettingMutationSchema = ZSiteSettingSchema; export const ZAdminUpdateSiteSettingMutationSchema = ZSiteSettingSchema;
export type TUpdateSiteSettingMutationSchema = z.infer<typeof ZUpdateSiteSettingMutationSchema>; export type TAdminUpdateSiteSettingMutationSchema = z.infer<
typeof ZAdminUpdateSiteSettingMutationSchema
>;
export const ZAdminResealDocumentMutationSchema = z.object({
id: z.number().min(1),
});
export type TAdminResealDocumentMutationSchema = z.infer<typeof ZAdminResealDocumentMutationSchema>;
export const ZAdminDeleteUserMutationSchema = z.object({
id: z.number().min(1),
email: z.string().email(),
});
export type TAdminDeleteUserMutationSchema = z.infer<typeof ZAdminDeleteUserMutationSchema>;

View File

@ -1,6 +1,8 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { env } from 'next-runtime-env'; 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 { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { compareSync } from '@documenso/lib/server-only/auth/hash'; import { compareSync } from '@documenso/lib/server-only/auth/hash';
import { createUser } from '@documenso/lib/server-only/user/create-user'; 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 }); await sendConfirmationToken({ email: user.email });
return user; return user;
} catch (err) { } catch (err) {
console.log(err);
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
throw AppError.parseErrorToTRPCError(error);
}
let message = let message =
'We were unable to create your account. Please review the information you provided and try again.'; 'We were unable to create your account. Please review the information you provided and try again.';

View File

@ -21,6 +21,15 @@ export const ZSignUpMutationSchema = z.object({
email: z.string().email(), email: z.string().email(),
password: ZPasswordSchema, password: ZPasswordSchema,
signature: z.string().min(1, { message: 'A signature is required.' }), 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>; export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;

View File

@ -1,5 +1,8 @@
import { TRPCError } from '@trpc/server'; 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 { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs'; import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; 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 { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
import { updatePassword } from '@documenso/lib/server-only/user/update-password'; import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; 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 { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc'; import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
import { import {
@ -19,6 +24,7 @@ import {
ZRetrieveUserByIdQuerySchema, ZRetrieveUserByIdQuerySchema,
ZUpdatePasswordMutationSchema, ZUpdatePasswordMutationSchema,
ZUpdateProfileMutationSchema, ZUpdateProfileMutationSchema,
ZUpdatePublicProfileMutationSchema,
} from './schema'; } from './schema';
export const profileRouter = router({ 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 updatePassword: authenticatedProcedure
.input(ZUpdatePasswordMutationSchema) .input(ZUpdatePasswordMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@ -159,9 +207,9 @@ export const profileRouter = router({
deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => {
try { try {
const user = ctx.user; return await deleteUser({
id: ctx.user.id,
return await deleteUser(user); });
} catch (err) { } catch (err) {
let message = 'We were unable to delete your account. Please try again.'; let message = 'We were unable to delete your account. Please try again.';

View File

@ -16,6 +16,17 @@ export const ZUpdateProfileMutationSchema = z.object({
signature: z.string(), 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({ export const ZUpdatePasswordMutationSchema = z.object({
currentPassword: ZCurrentPasswordSchema, currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema, password: ZPasswordSchema,

View 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';

View File

@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( 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, className,
{ {
'ring-2 !ring-red-500 transition-all': props['aria-invalid'], 'ring-2 !ring-red-500 transition-all': props['aria-invalid'],

View File

@ -3,7 +3,8 @@
import * as React from 'react'; import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle'; 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'; import { cn } from '../lib/utils';

View File

@ -27,7 +27,8 @@
"cache": false "cache": false
}, },
"test:e2e": { "test:e2e": {
"dependsOn": ["^build"] "dependsOn": ["^build"],
"cache": false
} }
}, },
"globalDependencies": ["**/.env.*local"], "globalDependencies": ["**/.env.*local"],