diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml
new file mode 100644
index 000000000..e1eb4da22
--- /dev/null
+++ b/.github/actions/cache-build/action.yml
@@ -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
diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml
new file mode 100644
index 000000000..77483a9a4
--- /dev/null
+++ b/.github/actions/node-install/action.yml
@@ -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'
diff --git a/.github/actions/playwright-install/action.yml b/.github/actions/playwright-install/action.yml
new file mode 100644
index 000000000..27d0e66b4
--- /dev/null
+++ b/.github/actions/playwright-install/action.yml
@@ -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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index deda53ff0..bebca8e85 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,7 @@
name: 'Continuous Integration'
on:
+ workflow_call:
push:
branches: ['main']
pull_request:
@@ -10,9 +11,6 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
-env:
- HUSKY: 0
-
jobs:
build_app:
name: Build App
@@ -23,20 +21,12 @@ jobs:
with:
fetch-depth: 2
- - name: Install Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 18
- cache: npm
-
- - name: Install dependencies
- run: npm ci
+ - uses: ./.github/actions/node-install
- name: Copy env
run: cp .env.example .env
- - name: Build
- run: npm run build
+ - uses: ./.github/actions/cache-build
build_docker:
name: Build Docker Image
@@ -47,5 +37,31 @@ jobs:
with:
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
- 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
diff --git a/.github/workflows/clean-cache.yml b/.github/workflows/clean-cache.yml
new file mode 100644
index 000000000..2cb13f661
--- /dev/null
+++ b/.github/workflows/clean-cache.yml
@@ -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
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 465041c0a..314dc7b7b 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -25,19 +25,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 18
- cache: npm
-
- - name: Install Dependencies
- run: npm ci
-
- name: Copy env
run: cp .env.example .env
- - name: Build Documenso
- run: npm run build
+ - uses: ./.github/actions/node-install
+
+ - uses: ./.github/actions/cache-build
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index 7b05458d9..12a7d9521 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -6,29 +6,21 @@ on:
branches: ['main']
jobs:
e2e_tests:
- name: "E2E Tests"
+ name: 'E2E Tests'
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 18
- cache: npm
- - name: Install dependencies
- run: npm ci
- name: Copy env
run: cp .env.example .env
+ - uses: ./.github/actions/node-install
+
- name: Start Services
run: npm run dx:up
- - name: Install Playwright Browsers
- run: npx playwright install --with-deps
-
- - name: Generate Prisma Client
- run: npm run prisma:generate -w @documenso/prisma
+ - uses: ./.github/actions/playwright-install
- name: Create the database
run: npm run prisma:migrate-dev
@@ -36,6 +28,8 @@ jobs:
- name: Seed the database
run: npm run prisma:seed
+ - uses: ./.github/actions/cache-build
+
- name: Run Playwright tests
run: npm run ci
@@ -43,7 +37,7 @@ jobs:
if: always()
with:
name: test-results
- path: "packages/app-tests/**/test-results/*"
+ path: 'packages/app-tests/**/test-results/*'
retention-days: 30
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
diff --git a/README.md b/README.md
index 32150a14d..cdb687264 100644
--- a/README.md
+++ b/README.md
@@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
To run Documenso locally, you will need
-- Node.js
+- Node.js (v18 or above)
- Postgres SQL Database
- Docker (optional)
diff --git a/apps/marketing/content/blog/launch-week-2-day-4.mdx b/apps/marketing/content/blog/launch-week-2-day-4.mdx
new file mode 100644
index 000000000..b6f5691fd
--- /dev/null
+++ b/apps/marketing/content/blog/launch-week-2-day-4.mdx
@@ -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
+---
+
+
+
+> 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)
+
+
+
+
+
+ Create unlimited custom webhooks with each plan.
+
+
+
+## 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: Support us on Twitter or anywhere to spread awareness for open signing! The best posts will receive merch codes ๐
+
+Best from Hamburg\
+Timur
diff --git a/apps/marketing/content/blog/launch-week-2-day-5.mdx b/apps/marketing/content/blog/launch-week-2-day-5.mdx
new file mode 100644
index 000000000..04d639206
--- /dev/null
+++ b/apps/marketing/content/blog/launch-week-2-day-5.mdx
@@ -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
+---
+
+
+
+> 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:
+
+
+
+
+
+ Async > Sync: Add public templates to your Documenso Link and let people sign whenever they are ready.
+
+
+
+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: Support us on Twitter or anywhere to spread awareness for open signing! The best posts will receive merch codes ๐
+
+Best from Hamburg\
+Timur
diff --git a/apps/marketing/public/blog/hooks.png b/apps/marketing/public/blog/hooks.png
new file mode 100644
index 000000000..9c324db0b
Binary files /dev/null and b/apps/marketing/public/blog/hooks.png differ
diff --git a/apps/marketing/public/blog/profile.png b/apps/marketing/public/blog/profile.png
new file mode 100644
index 000000000..b216e9758
Binary files /dev/null and b/apps/marketing/public/blog/profile.png differ
diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx
index dd1a46418..a7c599b36 100644
--- a/apps/marketing/src/app/(marketing)/layout.tsx
+++ b/apps/marketing/src/app/(marketing)/layout.tsx
@@ -2,8 +2,12 @@
import React, { useEffect, useState } from 'react';
+import Image from 'next/image';
import { usePathname } from 'next/navigation';
+import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
+import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Footer } from '~/components/(marketing)/footer';
@@ -17,6 +21,10 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
const [scrollY, setScrollY] = useState(0);
const pathname = usePathname();
+ const { getFlag } = useFeatureFlags();
+
+ const showProfilesAnnouncementBar = getFlag('marketing_profiles_announcement_bar');
+
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
@@ -38,6 +46,31 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
'bg-background/50 backdrop-blur-md': scrollY > 5,
})}
>
+ {showProfilesAnnouncementBar && (
+
+
+
+
+
+
+ Claim your documenso public profile username now!{' '}
+
documenso.com/u/yourname
+
+
+
+ )}
+
diff --git a/apps/marketing/src/app/(marketing)/singleplayer/[token]/success/page.tsx b/apps/marketing/src/app/(marketing)/singleplayer/[token]/success/page.tsx
index fbf020c38..51fbaff36 100644
--- a/apps/marketing/src/app/(marketing)/singleplayer/[token]/success/page.tsx
+++ b/apps/marketing/src/app/(marketing)/singleplayer/[token]/success/page.tsx
@@ -27,7 +27,7 @@ export default async function SinglePlayerModeSuccessPage({
return notFound();
}
- const signatures = await getRecipientSignatures({ recipientId: document.Recipient.id });
+ const signatures = await getRecipientSignatures({ recipientId: document.Recipient[0].id });
return ;
}
diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
index 9f1ebb289..4c1162599 100644
--- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
+++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
@@ -191,7 +191,7 @@ export const SinglePlayerClient = () => {
Create a{' '}
diff --git a/apps/marketing/src/components/(marketing)/callout.tsx b/apps/marketing/src/components/(marketing)/callout.tsx
index 72ae3907b..990aa163b 100644
--- a/apps/marketing/src/components/(marketing)/callout.tsx
+++ b/apps/marketing/src/components/(marketing)/callout.tsx
@@ -40,9 +40,9 @@ export const Callout = ({ starCount }: CalloutProps) => {
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
- Get the Early Adopters Plan
-
- $30/mo. forever!
+ Claim Community Plan
+
+ $30/mo
diff --git a/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx b/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx
index ee123d7ad..b80b2fe8c 100644
--- a/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx
+++ b/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx
@@ -1,4 +1,4 @@
-import { HTMLAttributes } from 'react';
+import type { HTMLAttributes } from 'react';
import Image from 'next/image';
diff --git a/apps/marketing/src/components/(marketing)/header.tsx b/apps/marketing/src/components/(marketing)/header.tsx
index e1813f7f6..915c13852 100644
--- a/apps/marketing/src/components/(marketing)/header.tsx
+++ b/apps/marketing/src/components/(marketing)/header.tsx
@@ -9,6 +9,7 @@ import Link from 'next/link';
import LogoImage from '@documenso/assets/logo.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
import { HamburgerMenu } from './mobile-hamburger';
import { MobileNavigation } from './mobile-navigation';
@@ -68,12 +69,18 @@ export const Header = ({ className, ...props }: HeaderProps) => {
Sign in
+
+
+
+ Sign up
+
+
{
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
- Get the Early Adopters Plan
-
- $30/mo. forever!
+ Claim Community Plan
+
+ $30/mo
@@ -224,8 +225,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
(in a non-legally binding, but heartfelt way)
{' '}
- and lock in the early supporter plan for forever, including everything we build this
- year.
+ and lock in the community plan for forever, including everything we build this year.
diff --git a/apps/marketing/src/components/(marketing)/mobile-navigation.tsx b/apps/marketing/src/components/(marketing)/mobile-navigation.tsx
index 982e2967a..434b30053 100644
--- a/apps/marketing/src/components/(marketing)/mobile-navigation.tsx
+++ b/apps/marketing/src/components/(marketing)/mobile-navigation.tsx
@@ -47,9 +47,13 @@ export const MENU_NAVIGATION_LINKS = [
text: 'Privacy',
},
{
- href: 'https://app.documenso.com/signin',
+ href: 'https://app.documenso.com/signin?utm_source=marketing-header',
text: 'Sign in',
},
+ {
+ href: 'https://app.documenso.com/signup?utm_source=marketing-header',
+ text: 'Sign up',
+ },
];
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx
index 748f7307f..ab35bcc90 100644
--- a/apps/marketing/src/components/(marketing)/pricing-table.tsx
+++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx
@@ -83,7 +83,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
-
+
Signup Now
@@ -114,7 +118,10 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
-
+
Signup Now
diff --git a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx
index d8a8e2c53..85edf2594 100644
--- a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx
+++ b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx
@@ -55,7 +55,7 @@ export const SinglePlayerModeSuccess = ({
@@ -65,7 +65,7 @@ export const SinglePlayerModeSuccess = ({
@@ -86,7 +86,7 @@ export const SinglePlayerModeSuccess = ({
Create a{' '}
diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx
index fe7502d27..15e3fbdeb 100644
--- a/apps/marketing/src/components/(marketing)/widget.tsx
+++ b/apps/marketing/src/components/(marketing)/widget.tsx
@@ -199,7 +199,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
className="bg-foreground/5 col-span-12 flex flex-col rounded-2xl p-6 lg:col-span-5"
onSubmit={handleSubmit(onFormSubmit)}
>
-
Sign up for the early adopters plan
+
Sign up to Community Plan
with Timur Ercan & Lucas Smith from Documenso
@@ -208,7 +208,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
-
+
Whatโs your email?
@@ -220,7 +220,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
@@ -265,11 +265,8 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
transform: 'translateX(25%)',
}}
>
-
- and your name?
+
+ And your name?
{
+ 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 (
+
+
+
+
+ resealDocument({ id: document.id })}
+ >
+ Reseal document
+
+
+
+
+ Attempts sealing the document again, useful for after a code change has occurred to
+ resolve an erroneous document.
+
+
+
+
+
+ Go to owner
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx
new file mode 100644
index 000000000..a22345457
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx
@@ -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 (
+
+
+
+
{document.title}
+
+
+
+ {document.deletedAt && (
+
+ Deleted
+
+ )}
+
+
+
+
+ Created on:
+
+
+ Last updated at:
+
+
+
+
+
+
Admin Actions
+
+
+
+
+
Recipients
+
+
+
+ {document.Recipient.map((recipient) => (
+
+
+
+
{recipient.name}
+
+ {recipient.email}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/recipient-item.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/recipient-item.tsx
new file mode 100644
index 000000000..3bf8c78ab
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/recipient-item.tsx
@@ -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;
+
+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({
+ 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 (
+
+
+
+
+
+
+
Fields
+
+
{row.original.id}
,
+ },
+ {
+ header: 'Type',
+ accessorKey: 'type',
+ cell: ({ row }) => {row.original.type}
,
+ },
+ {
+ header: 'Inserted',
+ accessorKey: 'inserted',
+ cell: ({ row }) => {row.original.inserted ? 'True' : 'False'}
,
+ },
+ {
+ header: 'Value',
+ accessorKey: 'customText',
+ cell: ({ row }) => {row.original.customText}
,
+ },
+ {
+ header: 'Signature',
+ accessorKey: 'signature',
+ cell: ({ row }) => (
+
+ {row.original.Signature?.typedSignature && (
+
{row.original.Signature.typedSignature}
+ )}
+
+ {row.original.Signature?.signatureImageAsBase64 && (
+
+ )}
+
+ ),
+ },
+ ]}
+ />
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
deleted file mode 100644
index 0fc660968..000000000
--- a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
+++ /dev/null
@@ -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;
- }
- >;
-};
-
-export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
- const [isPending, startTransition] = useTransition();
-
- const updateSearchParams = useUpdateSearchParams();
-
- const onPaginationChange = (page: number, perPage: number) => {
- startTransition(() => {
- updateSearchParams({
- page,
- perPage,
- });
- });
- };
-
- return (
-
-
,
- },
- {
- header: 'Title',
- accessorKey: 'title',
- cell: ({ row }) => {
- return (
-
- {row.original.title}
-
- );
- },
- },
- {
- 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 (
-
-
-
-
-
- {avatarFallbackText}
-
-
-
-
-
-
-
- {avatarFallbackText}
-
-
-
-
- {row.original.User.name}
- {row.original.User.email}
-
-
-
- );
- },
- },
- {
- header: 'Last updated',
- accessorKey: 'updatedAt',
- cell: ({ row }) => ,
- },
- {
- header: 'Status',
- accessorKey: 'status',
- cell: ({ row }) => ,
- },
- ]}
- data={results.data}
- perPage={results.perPage}
- currentPage={results.currentPage}
- totalPages={results.totalPages}
- onPaginationChange={onPaginationChange}
- >
- {(table) => }
-
-
- {isPending && (
-
-
-
- )}
-
- );
-};
diff --git a/apps/web/src/app/(dashboard)/admin/documents/document-results.tsx b/apps/web/src/app/(dashboard)/admin/documents/document-results.tsx
new file mode 100644
index 000000000..b7e235981
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/admin/documents/document-results.tsx
@@ -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 (
+
+
setTerm(e.target.value)}
+ />
+
+
+
,
+ },
+ {
+ header: 'Title',
+ accessorKey: 'title',
+ cell: ({ row }) => {
+ return (
+
+ {row.original.title}
+
+ );
+ },
+ },
+ {
+ header: 'Status',
+ accessorKey: 'status',
+ cell: ({ row }) => ,
+ },
+ {
+ 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 (
+
+
+
+
+
+ {avatarFallbackText}
+
+
+
+
+
+
+
+
+ {avatarFallbackText}
+
+
+
+
+ {row.original.User.name}
+ {row.original.User.email}
+
+
+
+ );
+ },
+ },
+ {
+ header: 'Last updated',
+ accessorKey: 'updatedAt',
+ cell: ({ row }) => ,
+ },
+ ]}
+ data={findDocumentsData?.data ?? []}
+ perPage={findDocumentsData?.perPage ?? 20}
+ currentPage={findDocumentsData?.currentPage ?? 1}
+ totalPages={findDocumentsData?.totalPages ?? 1}
+ onPaginationChange={onPaginationChange}
+ >
+ {(table) => }
+
+
+ {isFindDocumentsLoading && (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/admin/documents/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/page.tsx
index 2fbbcd4dc..96e4dcef8 100644
--- a/apps/web/src/app/(dashboard)/admin/documents/page.tsx
+++ b/apps/web/src/app/(dashboard)/admin/documents/page.tsx
@@ -1,28 +1,12 @@
-import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
-
-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,
- });
+import { AdminDocumentResults } from './document-results';
+export default function AdminDocumentsPage() {
return (
);
diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx
new file mode 100644
index 000000000..42e523ece
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx
@@ -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 (
+
+
+
+
Delete Account
+
+ Delete the users account and all its contents. This action is irreversible and will
+ cancel their subscription, so proceed with caution.
+
+
+
+
+
+
+ Delete Account
+
+
+
+
+ Delete Account
+
+
+
+ This action is not reversible. Please be certain.
+
+
+
+
+
+
+ To confirm, please enter the accounts email address ({user.email}).
+
+
+ setEmail(e.target.value)}
+ />
+
+
+
+
+ {isDeletingUser ? 'Deleting account...' : 'Delete Account'}
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
index 3bd909623..b9068329a 100644
--- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
+++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
@@ -7,7 +7,7 @@ import { useForm } from 'react-hook-form';
import type { z } from 'zod';
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 {
Form,
@@ -20,9 +20,10 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
+import { DeleteUserDialog } from './delete-user-dialog';
import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
-const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
+const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
type TUserFormSchema = z.infer;
@@ -137,6 +138,10 @@ export default function UserPage({ params }: { params: { id: number } }) {
+
+
+
+ {user && }
);
}
diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx
index 577e0739a..1a5d2f554 100644
--- a/apps/web/src/app/(dashboard)/admin/users/page.tsx
+++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx
@@ -19,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage),
- getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
+ getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY).catch(() => []),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx
index 67f432a13..6ce0ce2c0 100644
--- a/apps/web/src/app/(dashboard)/documents/page.tsx
+++ b/apps/web/src/app/(dashboard)/documents/page.tsx
@@ -1,7 +1,10 @@
import type { Metadata } from 'next';
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+
import type { DocumentsPageViewProps } from './documents-page-view';
import { DocumentsPageView } from './documents-page-view';
+import { UpcomingProfileClaimTeaser } from './upcoming-profile-claim-teaser';
export type DocumentsPageProps = {
searchParams?: DocumentsPageViewProps['searchParams'];
@@ -11,6 +14,12 @@ export const metadata: Metadata = {
title: 'Documents',
};
-export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
- return
;
+export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
+ const { user } = await getRequiredServerComponentSession();
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/apps/web/src/app/(dashboard)/documents/upcoming-profile-claim-teaser.tsx b/apps/web/src/app/(dashboard)/documents/upcoming-profile-claim-teaser.tsx
new file mode 100644
index 000000000..a2b3aea69
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/upcoming-profile-claim-teaser.tsx
@@ -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 (
+
setClaimed(true)}
+ user={user}
+ />
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx
new file mode 100644
index 000000000..c894113b6
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx
@@ -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 (
+ <>
+
+
+
{user.url ? 'Update your profile' : 'Claim your profile'}
+
+ {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.'}
+
+
+
+
+ setOpen(true)}>{user.url ? 'Update Now' : 'Claim Now'}
+
+
+
+
+ >
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx
index 11cfc8515..669c149b5 100644
--- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx
+++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx
@@ -5,6 +5,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { ProfileForm } from '~/components/forms/profile';
+import { ClaimProfileAlertDialog } from './claim-profile-alert-dialog';
import { DeleteAccountDialog } from './delete-account-dialog';
export const metadata: Metadata = {
@@ -18,9 +19,13 @@ export default async function ProfileSettingsPage() {
);
}
diff --git a/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx
index 6e183b0c7..2b5906177 100644
--- a/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx
+++ b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx
@@ -1,5 +1,8 @@
import type { Metadata } from 'next';
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+
+import ActivityPageBackButton from '../../../../../components/(dashboard)/settings/layout/activity-back';
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
export const metadata: Metadata = {
@@ -9,11 +12,14 @@ export const metadata: Metadata = {
export default function SettingsSecurityActivityPage() {
return (
-
Security activity
-
-
- View all recent security activity related to your account.
-
+
+
+
diff --git a/apps/web/src/app/(unauthenticated)/check-email/page.tsx b/apps/web/src/app/(unauthenticated)/check-email/page.tsx
index 94b410a8e..01f2b389d 100644
--- a/apps/web/src/app/(unauthenticated)/check-email/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/check-email/page.tsx
@@ -9,17 +9,19 @@ export const metadata: Metadata = {
export default function ForgotPasswordPage() {
return (
-
-
Email sent!
+
+
+
Email sent!
-
- A password reset email has been sent, if you have an account you should see it in your inbox
- shortly.
-
+
+ A password reset email has been sent, if you have an account you should see it in your
+ inbox shortly.
+
-
- Return to sign in
-
+
+ Return to sign in
+
+
);
}
diff --git a/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx b/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
index 36c023027..e93c8947c 100644
--- a/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
@@ -9,22 +9,24 @@ export const metadata: Metadata = {
export default function ForgotPasswordPage() {
return (
-
-
Forgot your password?
+
+
+
Forgot your password?
-
- No worries, it happens! Enter your email and we'll email you a special link to reset your
- password.
-
+
+ No worries, it happens! Enter your email and we'll email you a special link to reset your
+ password.
+
-
+
-
- Remembered your password?{' '}
-
- Sign In
-
-
+
+ Remembered your password?{' '}
+
+ Sign In
+
+
+
);
}
diff --git a/apps/web/src/app/(unauthenticated)/layout.tsx b/apps/web/src/app/(unauthenticated)/layout.tsx
index 43c6d291f..03a73278f 100644
--- a/apps/web/src/app/(unauthenticated)/layout.tsx
+++ b/apps/web/src/app/(unauthenticated)/layout.tsx
@@ -10,9 +10,9 @@ type UnauthenticatedLayoutProps = {
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
return (
-
-
-
+
+
+
-
{children}
+
{children}
);
diff --git a/apps/web/src/app/(unauthenticated)/reset-password/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/reset-password/[token]/page.tsx
index 04afd2c4d..1d469eb74 100644
--- a/apps/web/src/app/(unauthenticated)/reset-password/[token]/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/reset-password/[token]/page.tsx
@@ -19,19 +19,21 @@ export default async function ResetPasswordPage({ params: { token } }: ResetPass
}
return (
-
-
Reset Password
+
+
+
Reset Password
-
Please choose your new password
+
Please choose your new password
-
+
-
- Don't have an account?{' '}
-
- Sign up
-
-
+
+ Don't have an account?{' '}
+
+ Sign up
+
+
+
);
}
diff --git a/apps/web/src/app/(unauthenticated)/reset-password/page.tsx b/apps/web/src/app/(unauthenticated)/reset-password/page.tsx
index 93cd41ebb..20d4bfe57 100644
--- a/apps/web/src/app/(unauthenticated)/reset-password/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/reset-password/page.tsx
@@ -9,17 +9,19 @@ export const metadata: Metadata = {
export default function ResetPasswordPage() {
return (
-
-
Unable to reset password
+
+
+
Unable to reset password
-
- The token you have used to reset your password is either expired or it never existed. If you
- have still forgotten your password, please request a new reset link.
-
+
+ The token you have used to reset your password is either expired or it never existed. If
+ you have still forgotten your password, please request a new reset link.
+
-
- Return to sign in
-
+
+ Return to sign in
+
+
);
}
diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx
index 50356a5bb..21136f2e6 100644
--- a/apps/web/src/app/(unauthenticated)/signin/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx
@@ -30,36 +30,27 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
}
return (
-
-
Sign in to your account
+
+
+
Sign in to your account
-
- Welcome back, we are lucky to have you.
-
-
-
-
- {NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
-
- Don't have an account?{' '}
-
- Sign up
-
+
+ Welcome back, we are lucky to have you.
- )}
-
-
- Forgot your password?
-
-
+
+
+
+
+ {NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
+
+ Don't have an account?{' '}
+
+ Sign up
+
+
+ )}
+
);
}
diff --git a/apps/web/src/app/(unauthenticated)/signup/page.tsx b/apps/web/src/app/(unauthenticated)/signup/page.tsx
index b9365e1d5..ad758a8e9 100644
--- a/apps/web/src/app/(unauthenticated)/signup/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/signup/page.tsx
@@ -1,5 +1,4 @@
import type { Metadata } from 'next';
-import Link from 'next/link';
import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
@@ -7,7 +6,7 @@ import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
-import { SignUpForm } from '~/components/forms/signup';
+import { SignUpFormV2 } from '~/components/forms/v2/signup';
export const metadata: Metadata = {
title: 'Sign Up',
@@ -34,26 +33,10 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
}
return (
-
-
Create a new account
-
-
- Create your account and start using state-of-the-art document signing. Open and beautiful
- signing is within your grasp.
-
-
-
-
-
- Already have an account?{' '}
-
- Sign in instead
-
-
-
+
);
}
diff --git a/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
index 634416fe3..289364ede 100644
--- a/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
@@ -29,16 +29,18 @@ export default async function AcceptInvitationPage({
if (!teamMemberInvite) {
return (
-
-
Invalid token
+
+
+
Invalid token
-
- 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.
+
-
- Return
-
+
+ Return
+
+
);
}
diff --git a/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx
index 53ad4461b..8d67ca218 100644
--- a/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx
@@ -22,16 +22,18 @@ export default async function VerifyTeamEmailPage({ params: { token } }: VerifyT
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
return (
-
-
Invalid link
+
+
+
Invalid link
-
- 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.
+
-
- Return
-
+
+ Return
+
+
);
}
diff --git a/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx
index 819b7e970..719ec5b76 100644
--- a/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx
@@ -25,17 +25,19 @@ export default async function VerifyTeamTransferPage({
if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
return (
-
-
Invalid link
+
+
+
Invalid link
-
- This link is invalid or has expired. Please contact your team to resend a transfer
- request.
-
+
+ This link is invalid or has expired. Please contact your team to resend a transfer
+ request.
+
-
- Return
-
+
+ Return
+
+
);
}
diff --git a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx
index f4b8b90d7..c5b6fbcff 100644
--- a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx
@@ -4,23 +4,25 @@ import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-
export default function UnverifiedAccount() {
return (
-
-
-
-
-
-
Confirm email
+
+
+
+
+
+
+
Confirm email
-
- To gain access to your account, please confirm your email address by clicking on the
- confirmation link from your inbox.
-
+
+ To gain access to your account, please confirm your email address by clicking on the
+ confirmation link from your inbox.
+
-
- 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.
+
-
+
+
);
diff --git a/apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx
index f671fb101..9536f937c 100644
--- a/apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx
@@ -14,15 +14,17 @@ export type PageProps = {
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
if (!token) {
return (
-
-
-
-
+
+
+
+
+
-
No token provided
-
- It seems that there is no token provided. Please check your email and try again.
-
+
No token provided
+
+ It seems that there is no token provided. Please check your email and try again.
+
+
);
}
@@ -31,22 +33,24 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
if (verified === null) {
return (
-
-
+
+
+
-
-
Something went wrong
+
+
Something went wrong
-
- We were unable to verify your email. If your email is not verified already, please try
- again.
-
+
+ We were unable to verify your email. If your email is not verified already, please try
+ again.
+
-
- Go back home
-
+
+ Go back home
+
+
);
@@ -54,17 +58,41 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
if (!verified) {
return (
+
+
+
+
+
+
+
+
Your token has expired!
+
+
+ It seems that the provided token has expired. We've just sent you another token,
+ please check your email and try again.
+
+
+
+ Go back home
+
+
+
+
+ );
+ }
+
+ return (
+
-
+
-
Your token has expired!
+
Email Confirmed!
- It seems that the provided token has expired. We've just sent you another token, please
- check your email and try again.
+ Your email has been successfully confirmed! You can now use all features of Documenso.
@@ -72,26 +100,6 @@ export default async function VerifyEmailPage({ params: { token } }: PageProps)
- );
- }
-
- return (
-
-
-
-
-
-
-
Email Confirmed!
-
-
- Your email has been successfully confirmed! You can now use all features of Documenso.
-
-
-
- Go back home
-
-
);
}
diff --git a/apps/web/src/app/(unauthenticated)/verify-email/page.tsx b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx
index 30d2baf16..f002ffda6 100644
--- a/apps/web/src/app/(unauthenticated)/verify-email/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx
@@ -11,22 +11,26 @@ export const metadata: Metadata = {
export default function EmailVerificationWithoutTokenPage() {
return (
-
-
-
-
+
+
+
+
+
-
-
Uh oh! Looks like you're missing a token
+
+
+ Uh oh! Looks like you're missing a token
+
-
- It seems that there is no token provided, if you are trying to verify your email please
- follow the link in your email.
-
+
+ It seems that there is no token provided, if you are trying to verify your email please
+ follow the link in your email.
+
-
- Go back home
-
+
+ Go back home
+
+
);
diff --git a/apps/web/src/app/api/v1/openapi/page.tsx b/apps/web/src/app/api/v1/openapi/page.tsx
index ca5c3a5ed..24e14c958 100644
--- a/apps/web/src/app/api/v1/openapi/page.tsx
+++ b/apps/web/src/app/api/v1/openapi/page.tsx
@@ -1,3 +1,11 @@
'use client';
-export { OpenApiDocsPage as default } from '@documenso/api/v1/api-documentation';
+import dynamic from 'next/dynamic';
+
+const Docs = dynamic(async () => import('@documenso/api/v1/api-documentation'), {
+ ssr: false,
+});
+
+export default function OpenApiDocsPage() {
+ return
;
+}
diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
index 9eef1f4bd..262e297d6 100644
--- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
+++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
@@ -57,7 +57,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
key={href}
href={`${rootHref}${href}`}
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(
`${rootHref}${href}`,
diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx
index 65bb63230..b0ede5b8b 100644
--- a/apps/web/src/components/(dashboard)/layout/header.tsx
+++ b/apps/web/src/components/(dashboard)/layout/header.tsx
@@ -86,7 +86,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
>
diff --git a/apps/web/src/components/(dashboard)/settings/layout/activity-back.tsx b/apps/web/src/components/(dashboard)/settings/layout/activity-back.tsx
new file mode 100644
index 000000000..d6ab1d080
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/settings/layout/activity-back.tsx
@@ -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 (
+
+ {
+ void router.back();
+ }}
+ >
+ Back
+
+
+ );
+}
diff --git a/apps/web/src/components/(dashboard)/settings/layout/header.tsx b/apps/web/src/components/(dashboard)/settings/layout/header.tsx
index 3fe567b81..5722d1985 100644
--- a/apps/web/src/components/(dashboard)/settings/layout/header.tsx
+++ b/apps/web/src/components/(dashboard)/settings/layout/header.tsx
@@ -1,15 +1,18 @@
import React from 'react';
+import { cn } from '@documenso/ui/lib/utils';
+
export type SettingsHeaderProps = {
title: string;
subtitle: string;
children?: React.ReactNode;
+ className?: string;
};
-export const SettingsHeader = ({ children, title, subtitle }: SettingsHeaderProps) => {
+export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
return (
<>
-
+
{title}
diff --git a/apps/web/src/components/forms/public-profile-claim-dialog.tsx b/apps/web/src/components/forms/public-profile-claim-dialog.tsx
new file mode 100644
index 000000000..dbd52fd27
--- /dev/null
+++ b/apps/web/src/components/forms/public-profile-claim-dialog.tsx
@@ -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
;
+
+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({
+ 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 (
+
+
+ {!claimed && (
+ <>
+
+
+ Introducing public profiles!
+
+
+
+ Reserve your Documenso public profile username
+
+
+
+
+
+
+
+ >
+ )}
+
+ {claimed && (
+ <>
+
+ All set!
+
+
+ We will let you know as soon as this features is launched
+
+
+
+
+
+
+ onOpenChange?.(false)}>
+ Can't wait!
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx
index ec690a568..1d6d32f1f 100644
--- a/apps/web/src/components/forms/signin.tsx
+++ b/apps/web/src/components/forms/signin.tsx
@@ -2,6 +2,7 @@
import { useState } from 'react';
+import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -195,9 +196,11 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
render={({ field }) => (
Email
+
+
)}
@@ -209,9 +212,19 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
render={({ field }) => (
Password
+
+
+
+
+ Forgot your password?
+
+
)}
diff --git a/apps/web/src/components/forms/v2/signup.tsx b/apps/web/src/components/forms/v2/signup.tsx
new file mode 100644
index 000000000..189116e01
--- /dev/null
+++ b/apps/web/src/components/forms/v2/signup.tsx
@@ -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;
+
+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('BASIC_DETAILS');
+
+ const utmSrc = searchParams?.get('utm_source') ?? null;
+
+ const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
+
+ const form = useForm({
+ 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 (
+
+
+
+
+
+
+
+
+
+
+ User profiles are coming soon!
+
+
+
+ {step === 'BASIC_DETAILS' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {step === 'BASIC_DETAILS' && (
+
+
Create a new account
+
+
+ Create your account and start using state-of-the-art document signing. Open and
+ beautiful signing is within your grasp.
+
+
+ )}
+
+ {step === 'CLAIM_USERNAME' && (
+
+
Claim your username now
+
+
+ You will get notified & be able to set up your documenso public profile when we launch
+ the feature.
+
+
+ )}
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/ui/user-profile-skeleton.tsx b/apps/web/src/components/ui/user-profile-skeleton.tsx
new file mode 100644
index 000000000..1c8f35b64
--- /dev/null
+++ b/apps/web/src/components/ui/user-profile-skeleton.tsx
@@ -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;
+ rows?: number;
+};
+
+export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => {
+ const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
+
+ return (
+
+
+ {baseUrl.host}/u/
+ {user.url}
+
+
+
+
+
+
+
{user.name}
+
+
+
+
+
+
+
+
+
+
+
+ Documents
+
+
+ {Array(rows)
+ .fill(0)
+ .map((_, index) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/web/src/components/ui/user-profile-timur.tsx b/apps/web/src/components/ui/user-profile-timur.tsx
new file mode 100644
index 000000000..e99a314b4
--- /dev/null
+++ b/apps/web/src/components/ui/user-profile-timur.tsx
@@ -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 (
+
+
+ {baseUrl.host}/u/timur
+
+
+
+
+
+
+
+
+
Timur Ercan
+
+
+
+
+
Hey Iโm Timur
+
+
+ Pick any of the following agreements below and start signing to get started
+
+
+
+
+
+
+ Documents
+
+
+ {Array(rows)
+ .fill(0)
+ .map((_, index) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/web/src/providers/posthog.tsx b/apps/web/src/providers/posthog.tsx
index 4bed6960e..dd90c813b 100644
--- a/apps/web/src/providers/posthog.tsx
+++ b/apps/web/src/providers/posthog.tsx
@@ -32,6 +32,7 @@ export function PostHogPageview() {
// Do nothing.
});
},
+ custom_campaign_params: ['src'],
});
}
diff --git a/package.json b/package.json
index 853de1c6b..96dc3e9b4 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev",
"dx:up": "docker compose -f docker/compose-services.yml up -d",
"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: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",
diff --git a/packages/api/v1/api-documentation.tsx b/packages/api/v1/api-documentation.tsx
index 6082d2d7f..fe394e603 100644
--- a/packages/api/v1/api-documentation.tsx
+++ b/packages/api/v1/api-documentation.tsx
@@ -8,3 +8,5 @@ import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export const OpenApiDocsPage = () => {
return ;
};
+
+export default OpenApiDocsPage;
diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts
index f1926fb2a..d59fccd1c 100644
--- a/packages/app-tests/e2e/fixtures/authentication.ts
+++ b/packages/app-tests/e2e/fixtures/authentication.ts
@@ -34,6 +34,7 @@ export const manualLogin = async ({
};
export const manualSignout = async ({ page }: ManualLoginOptions) => {
+ await page.waitForTimeout(1000);
await page.getByTestId('menu-switcher').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts
index 57c25bb26..9c9500053 100644
--- a/packages/app-tests/e2e/test-auth-flow.spec.ts
+++ b/packages/app-tests/e2e/test-auth-flow.spec.ts
@@ -29,7 +29,10 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
await page.mouse.up();
}
- await page.getByRole('button', { name: 'Sign Up', exact: true }).click();
+ await page.getByRole('button', { name: 'Next', exact: true }).click();
+ await page.getByLabel('Public profile username').fill('username-123');
+
+ await page.getByRole('button', { name: 'Complete', exact: true }).click();
await page.waitForURL('/unverified-account');
diff --git a/packages/assets/images/background-lw-2.png b/packages/assets/images/background-lw-2.png
new file mode 100644
index 000000000..f65793d6a
Binary files /dev/null and b/packages/assets/images/background-lw-2.png differ
diff --git a/packages/assets/images/community-cards.png b/packages/assets/images/community-cards.png
new file mode 100644
index 000000000..fe9b7edb4
Binary files /dev/null and b/packages/assets/images/community-cards.png differ
diff --git a/packages/assets/images/profile-claim-teaser.png b/packages/assets/images/profile-claim-teaser.png
new file mode 100644
index 000000000..b388de0d2
Binary files /dev/null and b/packages/assets/images/profile-claim-teaser.png differ
diff --git a/packages/assets/images/timur.png b/packages/assets/images/timur.png
new file mode 100644
index 000000000..2adf31596
Binary files /dev/null and b/packages/assets/images/timur.png differ
diff --git a/packages/lib/client-only/hooks/use-copy-share-link.ts b/packages/lib/client-only/hooks/use-copy-share-link.ts
index 255949e3c..cff552e8f 100644
--- a/packages/lib/client-only/hooks/use-copy-share-link.ts
+++ b/packages/lib/client-only/hooks/use-copy-share-link.ts
@@ -1,5 +1,5 @@
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';
diff --git a/packages/lib/constants/feature-flags.ts b/packages/lib/constants/feature-flags.ts
index ac476bc70..ebd09c73a 100644
--- a/packages/lib/constants/feature-flags.ts
+++ b/packages/lib/constants/feature-flags.ts
@@ -25,6 +25,7 @@ export const LOCAL_FEATURE_FLAGS: Record = {
app_teams: true,
app_document_page_view_history_sheet: false,
marketing_header_single_player_mode: false,
+ marketing_profiles_announcement_bar: true,
} as const;
/**
diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts
index 3337bab4c..f43f9c3ba 100644
--- a/packages/lib/errors/app-error.ts
+++ b/packages/lib/errors/app-error.ts
@@ -18,6 +18,8 @@ export enum AppErrorCode {
'RETRY_EXCEPTION' = 'RetryException',
'SCHEMA_FAILED' = 'SchemaFailed',
'TOO_MANY_REQUESTS' = 'TooManyRequests',
+ 'PROFILE_URL_TAKEN' = 'ProfileUrlTaken',
+ 'PREMIUM_PROFILE_URL' = 'PremiumProfileUrl',
}
const genericErrorCodeToTrpcErrorCodeMap: Record = {
@@ -32,6 +34,8 @@ const genericErrorCodeToTrpcErrorCodeMap: Record = {
[AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS',
+ [AppErrorCode.PROFILE_URL_TAKEN]: 'BAD_REQUEST',
+ [AppErrorCode.PREMIUM_PROFILE_URL]: 'BAD_REQUEST',
};
export const ZAppErrorJsonSchema = z.object({
diff --git a/packages/lib/server-only/admin/get-entire-document.ts b/packages/lib/server-only/admin/get-entire-document.ts
new file mode 100644
index 000000000..e74ee4c7b
--- /dev/null
+++ b/packages/lib/server-only/admin/get-entire-document.ts
@@ -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;
+};
diff --git a/packages/lib/server-only/admin/update-recipient.ts b/packages/lib/server-only/admin/update-recipient.ts
new file mode 100644
index 000000000..dcd826476
--- /dev/null
+++ b/packages/lib/server-only/admin/update-recipient.ts
@@ -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,
+ },
+ });
+};
diff --git a/packages/lib/server-only/document/get-document-by-token.ts b/packages/lib/server-only/document/get-document-by-token.ts
index 18f9a5161..d242e72fd 100644
--- a/packages/lib/server-only/document/get-document-by-token.ts
+++ b/packages/lib/server-only/document/get-document-by-token.ts
@@ -70,6 +70,6 @@ export const getDocumentAndRecipientByToken = async ({
return {
...result,
- Recipient: result.Recipient[0],
+ Recipient: result.Recipient,
};
};
diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts
index 8f39e3d25..95b7d9dc4 100644
--- a/packages/lib/server-only/document/seal-document.ts
+++ b/packages/lib/server-only/document/seal-document.ts
@@ -2,7 +2,7 @@
import { nanoid } from 'nanoid';
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 { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@@ -22,12 +22,14 @@ import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = {
documentId: number;
sendEmail?: boolean;
+ isResealing?: boolean;
requestMetadata?: RequestMetadata;
};
export const sealDocument = async ({
documentId,
sendEmail = true,
+ isResealing = false,
requestMetadata,
}: SealDocumentOptions) => {
'use server';
@@ -78,11 +80,43 @@ export const sealDocument = async ({
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
const pdfData = await getFile(documentData);
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) {
await insertFieldInPDF(doc, field);
}
@@ -134,7 +168,7 @@ export const sealDocument = async ({
});
});
- if (sendEmail) {
+ if (sendEmail && !isResealing) {
await sendCompletedEmail({ documentId, requestMetadata });
}
diff --git a/packages/lib/server-only/user/create-user.ts b/packages/lib/server-only/user/create-user.ts
index 1852dc12e..dbcec9efb 100644
--- a/packages/lib/server-only/user/create-user.ts
+++ b/packages/lib/server-only/user/create-user.ts
@@ -7,15 +7,17 @@ import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/pri
import { IS_BILLING_ENABLED } from '../../constants/app';
import { SALT_ROUNDS } from '../../constants/auth';
+import { AppError, AppErrorCode } from '../../errors/app-error';
export interface CreateUserOptions {
name: string;
email: string;
password: string;
signature?: string | null;
+ url?: string;
}
-export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
+export const createUser = async ({ name, email, password, signature, url }: CreateUserOptions) => {
const hashedPassword = await hash(password, SALT_ROUNDS);
const userExists = await prisma.user.findFirst({
@@ -28,6 +30,22 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
throw new Error('User already exists');
}
+ if (url) {
+ const urlExists = await prisma.user.findFirst({
+ where: {
+ url,
+ },
+ });
+
+ if (urlExists) {
+ throw new AppError(
+ AppErrorCode.PROFILE_URL_TAKEN,
+ 'Profile username is taken',
+ 'The profile username is already taken',
+ );
+ }
+ }
+
const user = await prisma.user.create({
data: {
name,
@@ -35,6 +53,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
password: hashedPassword,
signature,
identityProvider: IdentityProvider.DOCUMENSO,
+ url,
},
});
diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts
index d6d4284b4..71f579c26 100644
--- a/packages/lib/server-only/user/delete-user.ts
+++ b/packages/lib/server-only/user/delete-user.ts
@@ -4,20 +4,18 @@ import { DocumentStatus } from '@documenso/prisma/client';
import { deletedAccountServiceAccount } from './service-accounts/deleted-account';
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({
where: {
- email: {
- contains: email,
- },
+ id,
},
});
if (!user) {
- throw new Error(`User with email ${email} not found`);
+ throw new Error(`User with ID ${id} not found`);
}
const serviceAccount = await deletedAccountServiceAccount();
diff --git a/packages/lib/server-only/user/update-public-profile.ts b/packages/lib/server-only/user/update-public-profile.ts
new file mode 100644
index 000000000..f70f02cf2
--- /dev/null
+++ b/packages/lib/server-only/user/update-public-profile.ts
@@ -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: '',
+ },
+ },
+ },
+ },
+ });
+};
diff --git a/packages/lib/server-only/webhooks/trigger/trigger-webhook.ts b/packages/lib/server-only/webhooks/trigger/trigger-webhook.ts
index d43d227ea..e0a5eaaaf 100644
--- a/packages/lib/server-only/webhooks/trigger/trigger-webhook.ts
+++ b/packages/lib/server-only/webhooks/trigger/trigger-webhook.ts
@@ -2,6 +2,7 @@ import type { WebhookTriggerEvents } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { sign } from '../../crypto/sign';
+import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
export type TriggerWebhookOptions = {
event: WebhookTriggerEvents;
@@ -19,6 +20,12 @@ export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWeb
teamId,
};
+ const registeredWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
+
+ if (registeredWebhooks.length === 0) {
+ return;
+ }
+
const signature = sign(body);
await Promise.race([
diff --git a/packages/prisma/migrations/20240220115435_add_public_profile_url_bio/migration.sql b/packages/prisma/migrations/20240220115435_add_public_profile_url_bio/migration.sql
new file mode 100644
index 000000000..719968aff
--- /dev/null
+++ b/packages/prisma/migrations/20240220115435_add_public_profile_url_bio/migration.sql
@@ -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;
diff --git a/packages/prisma/migrations/20240227111633_rework_user_profiles/migration.sql b/packages/prisma/migrations/20240227111633_rework_user_profiles/migration.sql
new file mode 100644
index 000000000..6bf9c0759
--- /dev/null
+++ b/packages/prisma/migrations/20240227111633_rework_user_profiles/migration.sql
@@ -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;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 0baf98bf2..b1bf9f985 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -43,7 +43,9 @@ model User {
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
+ url String? @unique
+ userProfile UserProfile?
VerificationToken VerificationToken[]
ApiToken ApiToken[]
Template Template[]
@@ -54,6 +56,13 @@ model User {
@@index([email])
}
+model UserProfile {
+ id Int @id
+ bio String?
+
+ User User? @relation(fields: [id], references: [id], onDelete: Cascade)
+}
+
enum UserSecurityAuditLogType {
ACCOUNT_PROFILE_UPDATE
ACCOUNT_SSO_LINK
diff --git a/packages/prisma/seed/pr-711-deletion-of-documents.ts b/packages/prisma/seed/pr-711-deletion-of-documents.ts
index 7542cdb84..5365ecf47 100644
--- a/packages/prisma/seed/pr-711-deletion-of-documents.ts
+++ b/packages/prisma/seed/pr-711-deletion-of-documents.ts
@@ -49,6 +49,7 @@ export const seedDatabase = async () => {
email: u.email,
password: hashSync(u.password),
emailVerified: new Date(),
+ url: u.email,
},
}),
),
diff --git a/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts b/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts
index 22e8897a9..0fe27b703 100644
--- a/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts
+++ b/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts
@@ -49,6 +49,7 @@ export const seedDatabase = async () => {
email: u.email,
password: hashSync(u.password),
emailVerified: new Date(),
+ url: u.email,
},
}),
),
diff --git a/packages/prisma/seed/pr-718-add-stepper-component.ts b/packages/prisma/seed/pr-718-add-stepper-component.ts
index 57a0ddc61..d436a97b1 100644
--- a/packages/prisma/seed/pr-718-add-stepper-component.ts
+++ b/packages/prisma/seed/pr-718-add-stepper-component.ts
@@ -23,6 +23,7 @@ export const seedDatabase = async () => {
email: TEST_USER.email,
password: hashSync(TEST_USER.password),
emailVerified: new Date(),
+ url: TEST_USER.email,
},
});
};
diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts
index f4dd714ed..353683a1d 100644
--- a/packages/prisma/seed/users.ts
+++ b/packages/prisma/seed/users.ts
@@ -21,6 +21,7 @@ export const seedUser = async ({
email,
password: hashSync(password),
emailVerified: verified ? new Date() : undefined,
+ url: name,
},
});
};
diff --git a/packages/prisma/types/document-with-recipient.ts b/packages/prisma/types/document-with-recipient.ts
index c55b99e67..32ecd29cb 100644
--- a/packages/prisma/types/document-with-recipient.ts
+++ b/packages/prisma/types/document-with-recipient.ts
@@ -5,6 +5,6 @@ export type DocumentWithRecipients = Document & {
};
export type DocumentWithRecipient = Document & {
- Recipient: Recipient;
+ Recipient: Recipient[];
documentData: DocumentData;
};
diff --git a/packages/signing/helpers/addSigningPlaceholder.ts b/packages/signing/helpers/addSigningPlaceholder.ts
index 6c7bb18e3..211a534ea 100644
--- a/packages/signing/helpers/addSigningPlaceholder.ts
+++ b/packages/signing/helpers/addSigningPlaceholder.ts
@@ -1,5 +1,13 @@
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 = {
pdf: Buffer;
@@ -39,6 +47,12 @@ export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOption
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);
let widgets = pages[0].node.get(PDFName.of('Annots'));
diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts
index 7d71ab346..5be3ad9db 100644
--- a/packages/trpc/server/admin-router/router.ts
+++ b/packages/trpc/server/admin-router/router.ts
@@ -1,14 +1,39 @@
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 { sealDocument } from '@documenso/lib/server-only/document/seal-document';
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 { ZUpdateProfileMutationByAdminSchema, ZUpdateSiteSettingMutationSchema } from './schema';
+import {
+ ZAdminDeleteUserMutationSchema,
+ ZAdminFindDocumentsQuerySchema,
+ ZAdminResealDocumentMutationSchema,
+ ZAdminUpdateProfileMutationSchema,
+ ZAdminUpdateRecipientMutationSchema,
+ ZAdminUpdateSiteSettingMutationSchema,
+} from './schema';
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
- .input(ZUpdateProfileMutationByAdminSchema)
+ .input(ZAdminUpdateProfileMutationSchema)
.mutation(async ({ 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
- .input(ZUpdateSiteSettingMutationSchema)
+ .input(ZAdminUpdateSiteSettingMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
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.',
+ });
+ }
+ }),
});
diff --git a/packages/trpc/server/admin-router/schema.ts b/packages/trpc/server/admin-router/schema.ts
index 0b99c8372..cfedb06ba 100644
--- a/packages/trpc/server/admin-router/schema.ts
+++ b/packages/trpc/server/admin-router/schema.ts
@@ -3,17 +3,48 @@ import z from 'zod';
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;
+
+export const ZAdminUpdateProfileMutationSchema = z.object({
id: z.number().min(1),
name: z.string().nullish(),
email: z.string().email().optional(),
roles: z.array(z.nativeEnum(Role)).optional(),
});
-export type TUpdateProfileMutationByAdminSchema = z.infer<
- typeof ZUpdateProfileMutationByAdminSchema
+export type TAdminUpdateProfileMutationSchema = z.infer;
+
+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;
+export type TAdminUpdateSiteSettingMutationSchema = z.infer<
+ typeof ZAdminUpdateSiteSettingMutationSchema
+>;
+
+export const ZAdminResealDocumentMutationSchema = z.object({
+ id: z.number().min(1),
+});
+
+export type TAdminResealDocumentMutationSchema = z.infer;
+
+export const ZAdminDeleteUserMutationSchema = z.object({
+ id: z.number().min(1),
+ email: z.string().email(),
+});
+
+export type TAdminDeleteUserMutationSchema = z.infer;
diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts
index 65fe8d296..16b370b3e 100644
--- a/packages/trpc/server/auth-router/router.ts
+++ b/packages/trpc/server/auth-router/router.ts
@@ -1,6 +1,8 @@
import { TRPCError } from '@trpc/server';
import { env } from 'next-runtime-env';
+import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { compareSync } from '@documenso/lib/server-only/auth/hash';
import { createUser } from '@documenso/lib/server-only/user/create-user';
@@ -21,14 +23,29 @@ export const authRouter = router({
});
}
- const { name, email, password, signature } = input;
+ const { name, email, password, signature, url } = input;
- const user = await createUser({ name, email, password, signature });
+ if (IS_BILLING_ENABLED() && url && url.length < 6) {
+ throw new AppError(
+ AppErrorCode.PREMIUM_PROFILE_URL,
+ 'Only subscribers can have a username shorter than 6 characters',
+ );
+ }
+
+ const user = await createUser({ name, email, password, signature, url });
await sendConfirmationToken({ email: user.email });
return user;
} catch (err) {
+ console.log(err);
+
+ const error = AppError.parseError(err);
+
+ if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
+ throw AppError.parseErrorToTRPCError(error);
+ }
+
let message =
'We were unable to create your account. Please review the information you provided and try again.';
diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts
index dbe42a25c..9a52f7fc2 100644
--- a/packages/trpc/server/auth-router/schema.ts
+++ b/packages/trpc/server/auth-router/schema.ts
@@ -21,6 +21,15 @@ export const ZSignUpMutationSchema = z.object({
email: z.string().email(),
password: ZPasswordSchema,
signature: z.string().min(1, { message: 'A signature is required.' }),
+ url: z
+ .string()
+ .trim()
+ .toLowerCase()
+ .min(1)
+ .regex(/^[a-z0-9-]+$/, {
+ message: 'Username can only container alphanumeric characters and dashes.',
+ })
+ .optional(),
});
export type TSignUpMutationSchema = z.infer;
diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts
index 2f636d87d..542ac2807 100644
--- a/packages/trpc/server/profile-router/router.ts
+++ b/packages/trpc/server/profile-router/router.ts
@@ -1,5 +1,8 @@
import { TRPCError } from '@trpc/server';
+import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
@@ -8,7 +11,9 @@ import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
+import { updatePublicProfile } from '@documenso/lib/server-only/user/update-public-profile';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
+import { SubscriptionStatus } from '@documenso/prisma/client';
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
import {
@@ -19,6 +24,7 @@ import {
ZRetrieveUserByIdQuerySchema,
ZUpdatePasswordMutationSchema,
ZUpdateProfileMutationSchema,
+ ZUpdatePublicProfileMutationSchema,
} from './schema';
export const profileRouter = router({
@@ -74,6 +80,48 @@ export const profileRouter = router({
}
}),
+ updatePublicProfile: authenticatedProcedure
+ .input(ZUpdatePublicProfileMutationSchema)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ const { url } = input;
+
+ if (IS_BILLING_ENABLED() && url.length <= 6) {
+ const subscriptions = await getSubscriptionsByUserId({
+ userId: ctx.user.id,
+ }).then((subscriptions) =>
+ subscriptions.filter((s) => s.status === SubscriptionStatus.ACTIVE),
+ );
+
+ if (subscriptions.length === 0) {
+ throw new AppError(
+ AppErrorCode.PREMIUM_PROFILE_URL,
+ 'Only subscribers can have a username shorter than 6 characters',
+ );
+ }
+ }
+
+ const user = await updatePublicProfile({
+ userId: ctx.user.id,
+ url,
+ });
+
+ return { success: true, url: user.url };
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
+ throw AppError.parseErrorToTRPCError(error);
+ }
+
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message:
+ 'We were unable to update your public profile. Please review the information you provided and try again.',
+ });
+ }
+ }),
+
updatePassword: authenticatedProcedure
.input(ZUpdatePasswordMutationSchema)
.mutation(async ({ input, ctx }) => {
@@ -159,9 +207,9 @@ export const profileRouter = router({
deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => {
try {
- const user = ctx.user;
-
- return await deleteUser(user);
+ return await deleteUser({
+ id: ctx.user.id,
+ });
} catch (err) {
let message = 'We were unable to delete your account. Please try again.';
diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts
index 522b13552..dc62f83ba 100644
--- a/packages/trpc/server/profile-router/schema.ts
+++ b/packages/trpc/server/profile-router/schema.ts
@@ -16,6 +16,17 @@ export const ZUpdateProfileMutationSchema = z.object({
signature: z.string(),
});
+export const ZUpdatePublicProfileMutationSchema = z.object({
+ url: z
+ .string()
+ .trim()
+ .toLowerCase()
+ .min(1, { message: 'Please enter a valid username.' })
+ .regex(/^[a-z0-9-]+$/, {
+ message: 'Username can only container alphanumeric characters and dashes.',
+ }),
+});
+
export const ZUpdatePasswordMutationSchema = z.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
diff --git a/packages/ui/icons/verified.tsx b/packages/ui/icons/verified.tsx
new file mode 100644
index 000000000..5984e603d
--- /dev/null
+++ b/packages/ui/icons/verified.tsx
@@ -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 (
+
+
+
+
+
+ );
+ },
+);
+
+VerifiedIcon.displayName = 'VerifiedIcon';
diff --git a/packages/ui/primitives/input.tsx b/packages/ui/primitives/input.tsx
index 1a5fba1bb..71b3cb521 100644
--- a/packages/ui/primitives/input.tsx
+++ b/packages/ui/primitives/input.tsx
@@ -10,7 +10,7 @@ const Input = React.forwardRef(