Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: email verification for registration #599

Merged
merged 18 commits into from Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web/src/app/(dashboard)/documents/upload-document.tsx
Expand Up @@ -6,6 +6,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';

import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';

import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
Expand All @@ -22,6 +23,7 @@ export type UploadDocumentProps = {

export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter();
const { data: session } = useSession();

const { toast } = useToast();

Expand Down Expand Up @@ -79,7 +81,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
<div className={cn('relative', className)}>
<DocumentDropzone
className="min-h-[40vh]"
disabled={remaining.documents === 0}
disabled={remaining.documents === 0 || !session?.user.emailVerified}
onDrop={onFileDrop}
/>

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/(dashboard)/layout.tsx
Expand Up @@ -10,6 +10,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-

import { CommandMenu } from '~/components/(dashboard)/common/command-menu';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';

Expand All @@ -31,6 +32,7 @@ export default async function AuthenticatedDashboardLayout({
return (
<NextAuthProvider session={session}>
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<CommandMenu />
<Header user={user} />

Expand Down
97 changes: 97 additions & 0 deletions apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx
@@ -0,0 +1,97 @@
import Link from 'next/link';

import { AlertTriangle, CheckCircle2, XCircle, XOctagon } from 'lucide-react';

import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { Button } from '@documenso/ui/primitives/button';

export type PageProps = {
params: {
token: string;
};
};

export default async function VerifyEmailPage({ params: { token } }: PageProps) {
if (!token) {
return (
<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>
</div>
);
}

const verified = await verifyEmail({ token });

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

<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}

if (!verified) {
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>
<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>
);
}

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>
);
}
28 changes: 28 additions & 0 deletions apps/web/src/app/(unauthenticated)/verify-email/page.tsx
@@ -0,0 +1,28 @@
import Link from 'next/link';

import { XCircle } from 'lucide-react';

import { Button } from '@documenso/ui/primitives/button';

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

<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}
123 changes: 123 additions & 0 deletions apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx
@@ -0,0 +1,123 @@
'use client';

import { useEffect, useState } from 'react';

import { AlertTriangle } from 'lucide-react';

import { ONE_SECOND } from '@documenso/lib/constants/time';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';

export type VerifyEmailBannerProps = {
email: string;
};

const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;

export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);

const [isButtonDisabled, setIsButtonDisabled] = useState(false);

const { mutateAsync: sendConfirmationEmail, isLoading } =
trpc.profile.sendConfirmationEmail.useMutation();

const onResendConfirmationEmail = async () => {
try {
setIsButtonDisabled(true);

await sendConfirmationEmail({ email: email });

toast({
title: 'Success',
description: 'Verification email sent successfully.',
});

setIsOpen(false);
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
} catch (err) {
setIsButtonDisabled(false);

toast({
title: 'Error',
description: 'Something went wrong while sending the confirmation email.',
variant: 'destructive',
});
}
};

useEffect(() => {
// Check localStorage to see if we've recently automatically displayed the dialog
// if it was within the past 24 hours, don't show it again
// otherwise, show it again and update the localStorage timestamp
const emailVerificationDialogLastShown = localStorage.getItem(
'emailVerificationDialogLastShown',
);

if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);

if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
return;
}
}

setIsOpen(true);

localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
}, []);

return (
<>
<div className="bg-yellow-200 dark:bg-yellow-400">
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-yellow-900">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
Verify your email address to unlock all features.
</div>

<div>
<Button
variant="ghost"
className="h-auto px-2.5 py-1.5 text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500"
disabled={isButtonDisabled}
onClick={() => setIsOpen(true)}
size="sm"
>
{isButtonDisabled ? 'Verification Email Sent' : 'Verify Now'}
</Button>
</div>
</div>
</div>

<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogTitle>Verify your email address</DialogTitle>

<DialogDescription>
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox and
click the link in the email to verify your account.
</DialogDescription>

<div>
<Button
disabled={isButtonDisabled}
loading={isLoading}
onClick={onResendConfirmationEmail}
>
{isLoading ? 'Sending...' : 'Resend Confirmation Email'}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};