diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx
index dd1a46418..c5f761853 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/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..32983d1f3 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
@@ -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?
;
+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..827ea1cbe
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx
@@ -0,0 +1,45 @@
+'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 (
+ <>
+
+
+
Claim your profile
+
+ Profiles are coming soon! Claim your profile username now to reserve your corner of the
+ signing revolution.
+
+
+
+
+ setOpen(true)}>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/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..713bde4b4
--- /dev/null
+++ b/apps/web/src/components/forms/v2/signup.tsx
@@ -0,0 +1,463 @@
+'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;
+
+ console.log({ formSTate: form.formState });
+
+ const { mutateAsync: signup } = trpc.auth.signup.useMutation();
+
+ const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => {
+ try {
+ await signup({ name, email, password, signature, url });
+
+ router.push(`/unverified-account`);
+
+ toast({
+ title: 'Registration Successful',
+ description:
+ 'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
+ duration: 5000,
+ });
+
+ analytics.capture('App: User Sign Up', {
+ email,
+ timestamp: new Date().toISOString(),
+ custom_campaign_params: { src: utmSrc },
+ });
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.PROFILE_URL_TAKEN) {
+ form.setError('url', {
+ type: 'manual',
+ message: 'This username has already been taken',
+ });
+ } else if (error.code === AppErrorCode.PREMIUM_PROFILE_URL) {
+ form.setError('url', {
+ type: 'manual',
+ message: error.message,
+ });
+ } else if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
+ toast({
+ title: 'An error occurred',
+ description: err.message,
+ variant: 'destructive',
+ });
+ } else {
+ toast({
+ title: 'An unknown error occurred',
+ description:
+ 'We encountered an unknown error while attempting to sign you up. Please try again later.',
+ variant: 'destructive',
+ });
+ }
+ }
+ };
+
+ const onSignUpWithGoogleClick = async () => {
+ try {
+ await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
+ } catch (err) {
+ toast({
+ title: 'An unknown error occurred',
+ description:
+ 'We encountered an unknown error while attempting to sign you Up. Please try again later.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ 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/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/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/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/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/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/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts
index 65fe8d296..3f199ac11 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 ((true || 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..f9f409aa6 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 }) => {
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(