feat: claim profile (#972)

Signed-off-by: Adithya Krishna <adithya@documenso.com>
This commit is contained in:
Lucas Smith
2024-02-29 15:29:47 +11:00
committed by GitHub
60 changed files with 1539 additions and 233 deletions

View File

@ -2,8 +2,12 @@
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Footer } from '~/components/(marketing)/footer';
@ -17,6 +21,10 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
const [scrollY, setScrollY] = useState(0);
const pathname = usePathname();
const { getFlag } = useFeatureFlags();
const showProfilesAnnouncementBar = getFlag('marketing_profiles_announcement_bar');
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
@ -38,6 +46,31 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
'bg-background/50 backdrop-blur-md': scrollY > 5,
})}
>
{showProfilesAnnouncementBar && (
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
<div className="absolute inset-0 -z-[1]">
<Image
src={launchWeekTwoImage}
className="h-full w-full object-cover"
alt="Launch Week 2"
/>
</div>
<div className="text-background text-center text-sm">
Claim your documenso public profile username now!{' '}
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
<a
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=marketing-announcement-bar`}
className="bg-background text-foreground rounded-md px-2.5 py-1 text-xs font-medium duration-300"
>
Claim Now
</a>
</div>
</div>
</div>
)}
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div>

View File

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

View File

@ -40,9 +40,9 @@ export const Callout = ({ starCount }: CalloutProps) => {
className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
>
Get the Early Adopters Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
$30/mo. forever!
Claim Community Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
$30/mo
</span>
</Button>

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

@ -86,7 +86,7 @@ export const SinglePlayerModeSuccess = ({
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
Create a{' '}
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=singleplayer`}
target="_blank"
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
>

View File

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

View File

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

View File

@ -0,0 +1,52 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { User } from '@documenso/prisma/client';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
export type UpcomingProfileClaimTeaserProps = {
user: User;
};
export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserProps) => {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [claimed, setClaimed] = useState(false);
const onOpenChange = useCallback(
(open: boolean) => {
if (!open && !claimed) {
toast({
title: 'Claim your profile later',
description: 'You can claim your profile later on by going to your profile settings!',
});
}
setOpen(open);
localStorage.setItem('app.hasShownProfileClaimDialog', 'true');
},
[claimed, toast],
);
useEffect(() => {
const hasShownProfileClaimDialog =
localStorage.getItem('app.hasShownProfileClaimDialog') === 'true';
if (!user.url && !hasShownProfileClaimDialog) {
onOpenChange(true);
}
}, [onOpenChange, user.url]);
return (
<ClaimPublicProfileDialogForm
open={open}
onOpenChange={onOpenChange}
onClaimed={() => setClaimed(true)}
user={user}
/>
);
};

View File

@ -0,0 +1,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 (
<>
<Alert
className={cn(
'flex flex-col items-center justify-between gap-4 p-6 md:flex-row',
className,
)}
variant="neutral"
>
<div>
<AlertTitle>Claim your profile</AlertTitle>
<AlertDescription className="mr-2">
Profiles are coming soon! Claim your profile username now to reserve your corner of the
signing revolution.
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Button onClick={() => setOpen(true)}>Claim Now</Button>
</div>
</Alert>
<ClaimPublicProfileDialogForm open={open} onOpenChange={setOpen} user={user} />
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -10,9 +10,9 @@ type UnauthenticatedLayoutProps = {
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
return (
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div className="relative flex w-full max-w-md items-center gap-x-24">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div>
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
<Image
src={backgroundPattern}
alt="background pattern"
@ -20,7 +20,7 @@ export default function UnauthenticatedLayout({ children }: UnauthenticatedLayou
/>
</div>
<div className="w-full">{children}</div>
<div className="relative w-full">{children}</div>
</div>
</main>
);

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
@ -7,7 +6,7 @@ import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignUpForm } from '~/components/forms/signup';
import { SignUpFormV2 } from '~/components/forms/v2/signup';
export const metadata: Metadata = {
title: 'Sign Up',
@ -34,26 +33,10 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
}
return (
<div>
<h1 className="text-4xl font-semibold">Create a new account</h1>
<p className="text-muted-foreground/60 mt-2 text-sm">
Create your account and start using state-of-the-art document signing. Open and beautiful
signing is within your grasp.
</p>
<SignUpForm
className="mt-4"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign in instead
</Link>
</p>
</div>
<SignUpFormV2
className="w-screen max-w-screen-2xl px-4 md:px-16"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,22 @@
'use client';
import { useRouter } from 'next/navigation';
import { Button } from '@documenso/ui/primitives/button';
export default function ActivityPageBackButton() {
const router = useRouter();
return (
<div>
<Button
className="flex-shrink-0"
variant="secondary"
onClick={() => {
void router.back();
}}
>
Back
</Button>
</div>
);
}

View File

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

View File

@ -0,0 +1,197 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '../ui/user-profile-skeleton';
export const ZClaimPublicProfileFormSchema = z.object({
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
});
export type TClaimPublicProfileFormSchema = z.infer<typeof ZClaimPublicProfileFormSchema>;
export type ClaimPublicProfileDialogFormProps = {
open: boolean;
onOpenChange?: (open: boolean) => void;
onClaimed?: () => void;
user: User;
};
export const ClaimPublicProfileDialogForm = ({
open,
onOpenChange,
onClaimed,
user,
}: ClaimPublicProfileDialogFormProps) => {
const { toast } = useToast();
const [claimed, setClaimed] = useState(false);
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
const form = useForm<TClaimPublicProfileFormSchema>({
values: {
url: user.url || '',
},
resolver: zodResolver(ZClaimPublicProfileFormSchema),
});
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ url }: TClaimPublicProfileFormSchema) => {
try {
await updatePublicProfile({
url,
});
setClaimed(true);
onClaimed?.();
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.PROFILE_URL_TAKEN) {
form.setError('url', {
type: 'manual',
message: 'This username is already taken',
});
} else if (error.code === AppErrorCode.PREMIUM_PROFILE_URL) {
form.setError('url', {
type: 'manual',
message: error.message,
});
} else if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
toast({
title: 'An error occurred',
description: error.userMessage ?? error.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to save your details. Please try again later.',
});
}
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent position="center" className="max-w-lg overflow-hidden">
{!claimed && (
<>
<DialogHeader>
<DialogTitle className="font-semi-bold text-center text-xl">
Introducing public profiles!
</DialogTitle>
<DialogDescription className="text-center">
Reserve your Documenso public profile username
</DialogDescription>
</DialogHeader>
<Image src={profileClaimTeaserImage} alt="profile claim teaser" />
<Form {...form}>
<form
className={cn(
'to-background -mt-32 flex w-full flex-col bg-gradient-to-b from-transparent to-15% pt-16 md:-mt-44',
)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="-mt-6 flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile username</FormLabel>
<FormControl>
<Input type="text" className="mb-2 mt-2" {...field} />
</FormControl>
<FormMessage />
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block truncate rounded-md px-2 py-1 text-sm">
{baseUrl.host}/u/{field.value || '<username>'}
</div>
</FormItem>
)}
/>
</fieldset>
<div className="mt-4 text-center">
<Button type="submit" loading={isSubmitting}>
Claim your username
</Button>
</div>
</form>
</Form>
</>
)}
{claimed && (
<>
<DialogHeader>
<DialogTitle className="font-semi-bold text-center text-xl">All set!</DialogTitle>
<DialogDescription className="text-center">
We will let you know as soon as this features is launched
</DialogDescription>
</DialogHeader>
<UserProfileSkeleton className="mt-4" user={user} rows={1} />
<div className="to-background -mt-12 flex w-full flex-col items-center bg-gradient-to-b from-transparent to-15% px-4 pt-8 md:-mt-12">
<Button className="w-full" onClick={() => onOpenChange?.(false)}>
Can't wait!
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
};

View File

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

View File

@ -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<typeof ZSignUpFormV2Schema>;
export type SignUpFormV2Props = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
};
export const SignUpFormV2 = ({
className,
initialEmail,
isGoogleSSOEnabled,
}: SignUpFormV2Props) => {
const { toast } = useToast();
const analytics = useAnalytics();
const router = useRouter();
const searchParams = useSearchParams();
const [step, setStep] = useState<SignUpStep>('BASIC_DETAILS');
const utmSrc = searchParams?.get('utm_source') ?? null;
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
const form = useForm<TSignUpFormV2Schema>({
values: {
name: '',
email: initialEmail ?? '',
password: '',
signature: '',
url: '',
},
mode: 'onBlur',
resolver: zodResolver(ZSignUpFormV2Schema),
});
const isSubmitting = form.formState.isSubmitting;
const name = form.watch('name');
const url = form.watch('url');
// To continue we need to make sure name, email, password and signature are valid
const canContinue =
form.formState.dirtyFields.name &&
form.formState.errors.name === undefined &&
form.formState.dirtyFields.email &&
form.formState.errors.email === undefined &&
form.formState.dirtyFields.password &&
form.formState.errors.password === undefined &&
form.formState.dirtyFields.signature &&
form.formState.errors.signature === undefined;
console.log({ formSTate: form.formState });
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => {
try {
await signup({ name, email, password, signature, url });
router.push(`/unverified-account`);
toast({
title: 'Registration Successful',
description:
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
duration: 5000,
});
analytics.capture('App: User Sign Up', {
email,
timestamp: new Date().toISOString(),
custom_campaign_params: { src: utmSrc },
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.PROFILE_URL_TAKEN) {
form.setError('url', {
type: 'manual',
message: 'This username has already been taken',
});
} else if (error.code === AppErrorCode.PREMIUM_PROFILE_URL) {
form.setError('url', {
type: 'manual',
message: error.message,
});
} else if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you up. Please try again later.',
variant: 'destructive',
});
}
}
};
const onSignUpWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
variant: 'destructive',
});
}
};
return (
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
<div className="absolute -inset-8 -z-[2] backdrop-blur">
<Image
src={communityCardsImage}
fill={true}
alt="community-cards"
className="dark:brightness-95 dark:contrast-[70%] dark:invert"
/>
</div>
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
User profiles are coming soon!
</div>
<AnimatePresence>
{step === 'BASIC_DETAILS' ? (
<motion.div className="w-full max-w-md" layoutId="user-profile">
<UserProfileTimur
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</motion.div>
) : (
<motion.div className="w-full max-w-md" layoutId="user-profile">
<UserProfileSkeleton
user={{ name, url }}
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
/>
</motion.div>
)}
</AnimatePresence>
<div />
</div>
</div>
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(800px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
{step === 'BASIC_DETAILS' && (
<div className="h-20">
<h1 className="text-2xl font-semibold">Create a new account</h1>
<p className="text-muted-foreground mt-2 text-sm">
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
</p>
</div>
)}
{step === 'CLAIM_USERNAME' && (
<div className="h-20">
<h1 className="text-2xl font-semibold">Claim your username now</h1>
<p className="text-muted-foreground mt-2 text-sm">
You will get notified & be able to set up your documenso public profile when we launch
the feature.
</p>
</div>
)}
<hr className="-mx-6 my-4" />
<Form {...form}>
<form
className="flex w-full flex-1 flex-col gap-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
{step === 'BASIC_DETAILS' && (
<fieldset
className={cn(
'flex h-[500px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[600px]',
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
<FormItem>
<FormLabel>Sign Here</FormLabel>
<FormControl>
<SignaturePad
className="h-36 w-full"
disabled={isSubmitting}
containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isGoogleSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Sign Up with Google
</Button>
</>
)}
<p className="text-muted-foreground mt-4 text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Sign in instead
</Link>
</p>
</fieldset>
)}
{step === 'CLAIM_USERNAME' && (
<fieldset
className={cn(
'flex h-[500px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[600px]',
)}
disabled={isSubmitting}
>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile username</FormLabel>
<FormControl>
<Input type="text" className="mb-2 mt-2 lowercase" {...field} />
</FormControl>
<FormMessage />
<div className="bg-muted/50 border-border text-muted-foreground mt-2 inline-block truncate rounded-md border px-2 py-1 text-sm lowercase">
{baseUrl.host}/u/{field.value || '<username>'}
</div>
</FormItem>
)}
/>
</fieldset>
)}
<div className="mt-6">
{step === 'BASIC_DETAILS' && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">Basic details</span> 1/2
</p>
)}
{step === 'CLAIM_USERNAME' && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">Claim username</span> 2/2
</p>
)}
<div className="bg-foreground/40 relative mt-4 h-1.5 rounded-full">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="bg-documenso absolute inset-y-0 left-0 rounded-full"
style={{
width: step === 'BASIC_DETAILS' ? '50%' : '100%',
}}
/>
</div>
</div>
<div className="flex items-center gap-x-4">
{/* Go back button, disabled if step is basic details */}
<Button
type="button"
size="lg"
variant="secondary"
className="flex-1"
disabled={step === 'BASIC_DETAILS'}
loading={form.formState.isSubmitting}
onClick={() => setStep('BASIC_DETAILS')}
>
Back
</Button>
{/* Continue button */}
{step === 'BASIC_DETAILS' && (
<Button
type="button"
size="lg"
className="flex-1 disabled:cursor-not-allowed"
disabled={!canContinue}
loading={form.formState.isSubmitting}
onClick={() => setStep('CLAIM_USERNAME')}
>
Next
</Button>
)}
{/* Sign up button */}
{step === 'CLAIM_USERNAME' && (
<Button
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
type="submit"
size="lg"
className="flex-1"
>
Complete
</Button>
)}
</div>
</form>
</Form>
</div>
</div>
);
};

View File

@ -0,0 +1,84 @@
'use client';
import { File, User2 } from 'lucide-react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { User } from '@documenso/prisma/client';
import { VerifiedIcon } from '@documenso/ui/icons/verified';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type UserProfileSkeletonProps = {
className?: string;
user: Pick<User, 'name' | 'url'>;
rows?: number;
};
export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => {
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
return (
<div
className={cn(
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
className,
)}
>
<div className="border-border bg-background text-muted-foreground inline-flex items-center rounded-md border px-2.5 py-1.5 text-sm">
<span>{baseUrl.host}/u/</span>
<span className="inline-block max-w-[8rem] truncate lowercase">{user.url}</span>
</div>
<div className="mt-4">
<div className="bg-primary/10 rounded-full p-1.5">
<div className="bg-background flex h-20 w-20 items-center justify-center rounded-full border-2">
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
</div>
</div>
</div>
<div className="mt-6">
<div className="flex items-center justify-center gap-x-2">
<h2 className="max-w-[12rem] truncate text-2xl font-semibold">{user.name}</h2>
<VerifiedIcon className="text-primary h-8 w-8" />
</div>
<div className="dark:bg-foreground/30 mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300" />
<div className="dark:bg-foreground/20 mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200" />
</div>
<div className="mt-8 w-full">
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
Documents
</div>
{Array(rows)
.fill(0)
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
>
<div className="flex items-center gap-x-2">
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
<div className="space-y-2">
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
</div>
</div>
<div className="flex-shrink-0">
<Button type="button" size="sm" className="pointer-events-none w-32">
Sign
</Button>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,87 @@
'use client';
import Image from 'next/image';
import { File } from 'lucide-react';
import timurImage from '@documenso/assets/images/timur.png';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { VerifiedIcon } from '@documenso/ui/icons/verified';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type UserProfileTimurProps = {
className?: string;
rows?: number;
};
export const UserProfileTimur = ({ className, rows = 2 }: UserProfileTimurProps) => {
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
return (
<div
className={cn(
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
className,
)}
>
<div className="border-border bg-background text-muted-foreground inline-block rounded-md border px-2.5 py-1.5 text-sm">
{baseUrl.host}/u/timur
</div>
<div className="mt-4">
<Image
src={timurImage}
className="h-20 w-20 rounded-full"
alt="image of timur ercan founder of documenso"
/>
</div>
<div className="mt-6">
<div className="flex items-center justify-center gap-x-2">
<h2 className="text-2xl font-semibold">Timur Ercan</h2>
<VerifiedIcon className="text-primary h-8 w-8" />
</div>
<p className="text-muted-foreground mt-4 max-w-[40ch] text-center text-sm">Hey Im Timur</p>
<p className="text-muted-foreground mt-1 max-w-[40ch] text-center text-sm">
Pick any of the following agreements below and start signing to get started
</p>
</div>
<div className="mt-8 w-full">
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
Documents
</div>
{Array(rows)
.fill(0)
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
>
<div className="flex items-center gap-x-2">
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
<div className="space-y-2">
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
</div>
</div>
<div className="flex-shrink-0">
<Button type="button" size="sm" className="pointer-events-none w-32">
Sign
</Button>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

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

View File

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

View File

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

View File

@ -0,0 +1,49 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type UpdatePublicProfileOptions = {
userId: number;
url: string;
};
export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOptions) => {
const isUrlTaken = await prisma.user.findFirst({
select: {
id: true,
},
where: {
id: {
not: userId,
},
url,
},
});
if (isUrlTaken) {
throw new AppError(
AppErrorCode.PROFILE_URL_TAKEN,
'Profile username is taken',
'The profile username is already taken',
);
}
return await prisma.user.update({
where: {
id: userId,
},
data: {
url,
userProfile: {
upsert: {
create: {
bio: '',
},
update: {
bio: '',
},
},
},
},
});
};

View File

@ -0,0 +1,25 @@
/*
Warnings:
- A unique constraint covering the columns `[profileURL]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "profileURL" TEXT;
-- CreateTable
CREATE TABLE "UserProfile" (
"profileURL" TEXT NOT NULL,
"profileBio" TEXT,
CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("profileURL")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserProfile_profileURL_key" ON "UserProfile"("profileURL");
-- CreateIndex
CREATE UNIQUE INDEX "User_profileURL_key" ON "User"("profileURL");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_profileURL_fkey" FOREIGN KEY ("profileURL") REFERENCES "UserProfile"("profileURL") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,37 @@
/*
Warnings:
- You are about to drop the column `profileURL` on the `User` table. All the data in the column will be lost.
- The primary key for the `UserProfile` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `profileBio` on the `UserProfile` table. All the data in the column will be lost.
- You are about to drop the column `profileURL` on the `UserProfile` table. All the data in the column will be lost.
- A unique constraint covering the columns `[url]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- Added the required column `id` to the `UserProfile` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "User" DROP CONSTRAINT "User_profileURL_fkey";
-- DropIndex
DROP INDEX "User_profileURL_key";
-- DropIndex
DROP INDEX "UserProfile_profileURL_key";
-- AlterTable
ALTER TABLE "User" DROP COLUMN "profileURL",
ADD COLUMN "url" TEXT;
-- AlterTable
ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_pkey",
DROP COLUMN "profileBio",
DROP COLUMN "profileURL",
ADD COLUMN "bio" TEXT,
ADD COLUMN "id" INTEGER NOT NULL,
ADD CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("id");
-- CreateIndex
CREATE UNIQUE INDEX "User_url_key" ON "User"("url");
-- AddForeignKey
ALTER TABLE "UserProfile" ADD CONSTRAINT "UserProfile_id_fkey" FOREIGN KEY ("id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -43,7 +43,9 @@ model User {
twoFactorSecret String?
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,6 +21,15 @@ export const ZSignUpMutationSchema = z.object({
email: z.string().email(),
password: ZPasswordSchema,
signature: z.string().min(1, { message: 'A signature is required.' }),
url: z
.string()
.trim()
.toLowerCase()
.min(1)
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
})
.optional(),
});
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;

View File

@ -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 }) => {

View File

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

View File

@ -0,0 +1,31 @@
import { forwardRef } from 'react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
export const VerifiedIcon: LucideIcon = forwardRef(
({ size = 24, color = 'currentColor', ...props }, ref) => {
return (
<svg
ref={ref}
width={size}
height={size}
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g id="badge, verified, award">
<path
id="Icon"
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.5457 2.89094C11.5779 1.70302 13.4223 1.70302 14.4545 2.89094L15.2585 3.81628C15.3917 3.96967 15.5947 4.04354 15.7954 4.0117L17.0061 3.81965C18.5603 3.57309 19.9732 4.75869 20.0003 6.33214L20.0214 7.55778C20.0249 7.76096 20.1329 7.94799 20.3071 8.05261L21.358 8.6837C22.7071 9.49389 23.0274 11.3103 22.0368 12.5331L21.2651 13.4855C21.1372 13.6434 21.0997 13.8561 21.1659 14.0482L21.5652 15.2072C22.0779 16.695 21.1557 18.2923 19.6109 18.5922L18.4075 18.8258C18.208 18.8646 18.0426 19.0034 17.9698 19.1931L17.5308 20.3376C16.9672 21.8069 15.234 22.4378 13.8578 21.6745L12.7858 21.08C12.6081 20.9814 12.3921 20.9814 12.2144 21.08L11.1424 21.6745C9.76623 22.4378 8.033 21.8069 7.4694 20.3376L7.03038 19.1931C6.9576 19.0034 6.79216 18.8646 6.59268 18.8258L5.38932 18.5922C3.84448 18.2923 2.92224 16.695 3.43495 15.2072L3.83431 14.0482C3.90052 13.8561 3.86302 13.6434 3.7351 13.4855L2.96343 12.5331C1.97279 11.3103 2.29307 9.49389 3.64218 8.6837L4.69306 8.05261C4.86728 7.94799 4.97526 7.76096 4.97875 7.55778L4.99985 6.33214C5.02694 4.75869 6.43987 3.57309 7.99413 3.81965L9.20481 4.0117C9.40551 4.04354 9.60845 3.96967 9.74173 3.81628L10.5457 2.89094ZM15.7072 11.2071C16.0977 10.8166 16.0977 10.1834 15.7072 9.79289C15.3167 9.40237 14.6835 9.40237 14.293 9.79289L11.5001 12.5858L10.7072 11.7929C10.3167 11.4024 9.68351 11.4024 9.29298 11.7929C8.90246 12.1834 8.90246 12.8166 9.29298 13.2071L10.4394 14.3536C11.0252 14.9393 11.975 14.9393 12.5608 14.3536L15.7072 11.2071Z"
fill={color}
/>
</g>
</svg>
);
},
);
VerifiedIcon.displayName = 'VerifiedIcon';

View File

@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border bg-transparent px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'bg-background border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
{
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],

View File

@ -3,7 +3,8 @@
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { VariantProps, cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';