feat: email and password login feature (#260)

* feat: email and password login feature

* fix: some updates

* feat: add password input component

* fix: build error

* chore: some minor improvements

---------

Co-authored-by: Puru D <[email protected]>
This commit is contained in:
Anik Dhabal Babu
2024-04-26 01:52:20 -05:00
committed by GitHub
co-authored by Puru D
parent 6ccd207595
commit 8d5a8e87ca
47 changed files with 1785 additions and 205 deletions
+12
View File
@@ -47,8 +47,10 @@
"@trpc/next": "^10.45.2",
"@trpc/react-query": "^10.43.6",
"@trpc/server": "^10.43.6",
"@types/bcryptjs": "^2.4.6",
"@types/papaparse": "^5.3.14",
"@wojtekmaj/react-hooks": "^1.18.1",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
@@ -6660,6 +6662,11 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
@@ -8500,6 +8507,11 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+2
View File
@@ -61,8 +61,10 @@
"@trpc/next": "^10.45.2",
"@trpc/react-query": "^10.43.6",
"@trpc/server": "^10.43.6",
"@types/bcryptjs": "^2.4.6",
"@types/papaparse": "^5.3.14",
"@wojtekmaj/react-hooks": "^1.18.1",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
@@ -0,0 +1,28 @@
/*
Warnings:
- The required column `id` was added to the `VerificationToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "password" TEXT;
-- AlterTable
ALTER TABLE "VerificationToken" ADD COLUMN "id" TEXT NOT NULL,
ADD CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("id");
-- CreateTable
CREATE TABLE "PasswordResetToken" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_email_token_key" ON "PasswordResetToken"("email", "token");
+11
View File
@@ -51,6 +51,7 @@ model User {
id String @id @default(cuid())
name String?
email String? @unique
password String?
emailVerified DateTime?
image String?
@@ -60,6 +61,7 @@ model User {
}
model VerificationToken {
id String @id @default(cuid())
identifier String
token String @unique
expires DateTime
@@ -67,6 +69,15 @@ model VerificationToken {
@@unique([identifier, token])
}
model PasswordResetToken {
id String @id @default(cuid())
email String
token String @unique
expires DateTime
@@unique([email, token])
}
model Company {
id String @id @default(cuid())
name String
+4
View File
@@ -1,6 +1,7 @@
import type { MemberStatusEnum } from "@/prisma/enums";
import { db } from "@/server/db";
import { faker } from "@faker-js/faker";
import bcrypt from "bcryptjs";
import colors from "colors";
colors.enable();
@@ -63,11 +64,14 @@ const seedTeam = async () => {
team.forEach(async (t) => {
// const { name, email, image, title, status, isOnboarded } = t
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash("P@ssw0rd!", salt);
const { name, email, title, status, isOnboarded } = t;
const user = await db.user.create({
data: {
name,
email,
password: hashedPassword,
// image,
},
});
+1 -1
View File
@@ -9,7 +9,7 @@ export default async function AuthenticatedLayout({
const session = await getServerAuthSession();
if (!session) {
redirect("/login");
redirect("/signin");
}
return <>{children}</>;
}
@@ -0,0 +1,8 @@
import { type Metadata } from "next";
import CheckEmailComponent from "@/components/onboarding/check-email";
export const metadata: Metadata = {
title: "Check Email",
};
export default function CheckEmail() {
return <CheckEmailComponent />;
}
@@ -0,0 +1,10 @@
import EmailSent from "@/components/onboarding/email-sent";
import { type Metadata } from "next";
export const metadata: Metadata = {
title: "Email Sent",
};
export default function EmailSentPage() {
return <EmailSent />;
}
@@ -0,0 +1,9 @@
import ForgotPassword from "@/components/onboarding/forgot-password";
import { type Metadata } from "next";
export const metadata: Metadata = {
title: "Forgot Password",
};
export default function ForgotPasswordPage() {
return <ForgotPassword />;
}
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { RiCheckboxCircleLine } from "@remixicon/react";
export const metadata: Metadata = {
title: "Password Updated",
};
export default function PasswordUpdated() {
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-white p-10 shadow">
<div className="flex flex-col gap-y-2 text-center">
<RiCheckboxCircleLine className="h-10 w-auto" />
<h1 className="text-2xl font-semibold tracking-tight">
Password Updated
</h1>
<p className="text-sm text-muted-foreground">
Your password has been updated successfully.
</p>
<Link href="/" className="mt-4">
<Button size="lg">Return to sign in</Button>
</Link>
</div>
</div>
</div>
);
}
@@ -0,0 +1,18 @@
import { type Metadata } from "next";
import { ResetPasswordForm } from "@/components/onboarding/reset-password";
export const metadata: Metadata = {
title: "Reset Password",
};
export type PageProps = {
params: {
token: string;
};
};
export default async function ResetPasswordPage({
params: { token },
}: PageProps) {
return <ResetPasswordForm token={token} />;
}
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = {
title: "Reset Password'",
};
export default function EmailVerificationWithoutTokenPage() {
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-white p-10 shadow">
<div className="flex flex-col gap-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Uh oh! Looks like you&apos;re missing a token
</h1>
<p className="text-sm text-muted-foreground">
It seems that there is no token provided, if you are trying to reset
your password please follow the link in your email.
</p>
<Link href="/" className="mt-4">
<Button size="lg">Go back home</Button>
</Link>
</div>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import SignInForm from "@/components/onboarding/signin";
import { IS_GOOGLE_AUTH_ENABLED } from "@/constants/auth";
import { getServerAuthSession } from "@/server/auth";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Sign In",
description: "Sign In to Captable, Inc.",
};
export default async function SignIn() {
const session = await getServerAuthSession();
if (session?.user) {
if (session?.user?.companyPublicId) {
return redirect(`/${session.user.companyPublicId}`);
} else {
return redirect("/onboarding");
}
}
return <SignInForm isGoogleAuthEnabled={IS_GOOGLE_AUTH_ENABLED} />;
}
+24
View File
@@ -0,0 +1,24 @@
import SignUpForm from "@/components/onboarding/signup";
import { IS_GOOGLE_AUTH_ENABLED } from "@/constants/auth";
import { getServerAuthSession } from "@/server/auth";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Sign Up",
description: "Sign Up to Captable, Inc.",
};
export default async function SignIn() {
const session = await getServerAuthSession();
if (session?.user) {
if (session?.user?.companyPublicId) {
return redirect(`/${session.user.companyPublicId}`);
} else {
return redirect("/onboarding");
}
}
return <SignUpForm isGoogleAuthEnabled={IS_GOOGLE_AUTH_ENABLED} />;
}
@@ -0,0 +1,18 @@
import { type Metadata } from "next";
import VerifyEmail from "@/components/onboarding/verify-email";
export const metadata: Metadata = {
title: "Verify Email",
};
export type PageProps = {
params: {
token: string;
};
};
export default async function VerifyEmailPage({
params: { token },
}: PageProps) {
return <VerifyEmail token={token} />;
}
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = {
title: "Verify Email",
};
export default function EmailVerificationWithoutTokenPage() {
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-white p-10 shadow">
<div className="flex flex-col gap-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Uh oh! Looks like you&apos;re missing a token
</h1>
<p className="text-sm text-muted-foreground">
It seems that there is no token provided, if you are trying to
verify your email please follow the link in your email.
</p>
<Link href="/" className="mt-4">
<Button size="lg">Go back home</Button>
</Link>
</div>
</div>
</div>
);
}
-21
View File
@@ -1,21 +0,0 @@
import LoginForm from "@/components/onboarding/login";
import { getServerAuthSession } from "@/server/auth";
import { redirect } from "next/navigation";
import { IS_GOOGLE_AUTH_ENABLED } from "@/constants/auth";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Login | Captable, Inc.",
description: "Login to Captable, Inc.",
};
export default async function AuthPage() {
const session = await getServerAuthSession();
if (session?.user?.companyPublicId) {
return redirect(`/${session.user.companyPublicId}`);
}
return <LoginForm isGoogleAuthEnabled={IS_GOOGLE_AUTH_ENABLED} />;
}
+1 -1
View File
@@ -8,5 +8,5 @@ export default async function HomePage() {
return redirect(`/${session.user.companyPublicId}`);
}
return redirect("/login");
return redirect("/signin");
}
+1 -1
View File
@@ -5,7 +5,7 @@ import Image from "next/image";
export const CaptableLogo = ({ className }: { className?: string }) => {
return (
<Image
className={cn("rounded", className)}
className={cn(className)}
height={500}
width={500}
src={logo}
@@ -0,0 +1,18 @@
import { CaptableLogo } from "@/components/common/logo";
interface LoginFormHeaderProps {
page?: string;
}
export function AuthFormHeader({ page }: LoginFormHeaderProps) {
return (
<div className="flex flex-col gap-y-2 text-center">
<CaptableLogo className="mb-3 h-10 w-auto" />
<h1 className="mb-2 text-2xl font-semibold tracking-tight">
{page === "signup"
? "Create your Captable, Inc. account"
: "Sign in to Captable, Inc."}
</h1>
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/trpc/react";
import { RiMailLine } from "@remixicon/react";
import { useSearchParams } from "next/navigation";
const CheckEmailComponent = () => {
const searchParams = useSearchParams();
const email = searchParams.get("email");
const { mutateAsync, isLoading } = api.auth.resendEmail.useMutation({
onSuccess: async ({ message }) => {
toast({
variant: "default",
title: "Resend Email",
description: message,
});
},
onError: () => {
toast({
variant: "destructive",
title: "Uh oh! Something went wrong.",
description: "Email not found!",
});
},
});
async function Resend() {
try {
if (email) {
await mutateAsync(email);
}
} catch (err) {
console.error(err);
}
}
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-white p-10 shadow">
<div className="flex flex-col gap-y-2 text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-teal-100">
<span className="text-teal-500">
<RiMailLine className="h-6 w-auto" />
</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight">
Check your email
</h1>
</div>
<div className="mb-2 text-center">
We&apos;ve sent an email to
<span className="text-sm font-bold"> {email} </span>. Please click the
link in the email to verify your account.
</div>
<Button onClick={Resend} disabled={!!!email} loading={isLoading}>
Resend verification email
</Button>
</div>
</div>
);
};
export default CheckEmailComponent;
@@ -0,0 +1,26 @@
"use client";
import { Button } from "@/components/ui/button";
import { RiMailLine } from "@remixicon/react";
import Link from "next/link";
const EmailSent = () => {
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-white p-10 shadow">
<div className="flex flex-col gap-y-2 text-center">
<RiMailLine className="mb-1 h-10 w-auto" />
<h1 className="text-2xl font-semibold tracking-tight">Email sent!</h1>
</div>
<div className="text-center">
A password reset email has been sent, if you have an account you
should see it in your inbox shortly.
</div>
<Link href="/" className="mt-4 text-center">
<Button size="lg">Back to sign in</Button>
</Link>
</div>
</div>
);
};
export default EmailSent;
@@ -0,0 +1,116 @@
"use client";
import { CaptableLogo } from "@/components/common/logo";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/trpc/react";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
const ForgotPassword = () => {
const inputSchema = z.object({ email: z.string().email() });
const form = useForm<z.infer<typeof inputSchema>>({
resolver: zodResolver(inputSchema),
defaultValues: {
email: "",
},
});
const isSubmitting = form.formState.isSubmitting;
const router = useRouter();
const { mutateAsync } = api.auth.forgotPassword.useMutation({
onSuccess: async ({ message }) => {
toast({
variant: "default",
title: "Reset email sent",
description: message,
});
router.replace("/email-sent");
},
onError: ({ message }) => {
toast({
variant: "destructive",
title: "Uh oh! Something went wrong.",
description: message,
});
},
});
const onSubmit = async (values: z.infer<typeof inputSchema>) => {
await mutateAsync(values.email);
};
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-white p-10 shadow">
<div className="flex flex-col gap-y-2 text-center">
<CaptableLogo className="mb-1 h-10 w-auto" />
<h1 className="text-2xl font-semibold tracking-tight">
Forgot your password?
</h1>
</div>
<div className="mb-2 text-center text-sm text-muted-foreground">
Enter your email and we&apos;ll email you a special link to reset your
password.
</div>
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="grid gap-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<div className="grid gap-4">
<FormLabel htmlFor="email">Email</FormLabel>
<FormControl>
<Input
id="email"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<Button loading={isSubmitting} type="submit">
Reset Password
</Button>
</div>
</form>
</Form>
<span className="text-center text-sm text-gray-500">
Remembered your password?{" "}
<Link
href="/signin"
className="underline underline-offset-4 hover:text-primary"
>
Sign In
</Link>
</span>
</>
</div>
</div>
);
};
export default ForgotPassword;
-138
View File
@@ -1,138 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { RiGoogleFill } from "@remixicon/react";
import * as z from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { LoginFormHeader } from "./login-form-header";
const loginSchema = z.object({
email: z.string().email(),
});
interface LoginFormProps {
isGoogleAuthEnabled: boolean;
}
const LoginForm = ({ isGoogleAuthEnabled }: LoginFormProps) => {
const form = useForm<z.infer<typeof loginSchema>>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: process.env.NODE_ENV === "development" ? "[email protected]" : "",
},
});
const [email, setEmail] = useState<undefined | string>(undefined);
async function onSubmit(values: z.infer<typeof loginSchema>) {
const email = values.email;
await signIn("email", {
email,
callbackUrl: "/onboarding",
redirect: false,
});
setEmail(values.email);
}
async function signInWithGoogle() {
await signIn("google", { callbackUrl: "/onboarding" });
}
const isSubmitting = form.formState.isSubmitting;
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="border-rounded grid w-full max-w-md grid-cols-1 gap-8 border bg-white p-10 shadow">
<LoginFormHeader email={email} />
{!email && (
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<div className="grid gap-1">
<FormLabel className="sr-only" htmlFor="email">
Email
</FormLabel>
<FormControl>
<Input
id="email"
placeholder="[email protected]"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<Button
loading={isSubmitting}
loadingText="Logging in..."
type="submit"
>
Login with Email
</Button>
</div>
</form>
</Form>
{isGoogleAuthEnabled && (
<>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t"></span>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button
disabled={isSubmitting}
variant="outline"
type="button"
onClick={signInWithGoogle}
>
<RiGoogleFill className="mr-2 h-4 w-4" />
Google
</Button>
</>
)}
</>
)}
</div>
</div>
);
};
export default LoginForm;
@@ -1,22 +0,0 @@
interface LoginFormHeaderProps {
email?: string;
}
export function LoginFormHeader({ email }: LoginFormHeaderProps) {
return (
<div className="mb-5 flex flex-col gap-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{email ? "Check your email" : "Login to Captable, Inc."}
</h1>
<p className="text-sm text-muted-foreground">
{email ? (
<>
We emailed a login link to <b>{email}</b>. Click the link to login
</>
) : (
<>Enter your email to login with a magic link</>
)}
</p>
</div>
);
}
@@ -0,0 +1,149 @@
"use client";
import { CaptableLogo } from "@/components/common/logo";
import { PasswordInput } from "@/components/ui/password-input";
import { api } from "@/trpc/react";
import { ZPasswordSchema } from "@/trpc/routers/auth/schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";
export const ZResetPasswordFormSchema = z
.object({
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
path: ["repeatedPassword"],
message: "Passwords don't match",
});
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
export type ResetPasswordFormProps = {
token: string;
};
export const ResetPasswordForm = ({ token }: ResetPasswordFormProps) => {
const router = useRouter();
const form = useForm<TResetPasswordFormSchema>({
values: {
password: "",
repeatedPassword: "",
},
resolver: zodResolver(ZResetPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync } = api.auth.newPassword.useMutation({
onSuccess: async ({ message }) => {
toast({
variant: "default",
title: "🎉 Password Updated",
description: message,
});
router.replace("/password-updated");
},
onError: ({ message }) => {
toast({
variant: "destructive",
title: "Uh oh! Something went wrong.",
description: message,
});
},
});
const onFormSubmit = async ({
password,
}: Omit<TResetPasswordFormSchema, "repeatedPassword">) => {
await mutateAsync({ password, token });
};
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-white p-10 shadow">
<div className="flex flex-col gap-y-2 text-center">
<CaptableLogo className="mb-1 h-10 w-auto" />
<h1 className="text-2xl font-semibold tracking-tight">
Reset your password
</h1>
</div>
<Form {...form}>
<form
className="space-y-8"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<div className="grid gap-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="grid gap-3">
<FormLabel htmlFor="password">Passowrd</FormLabel>
<FormControl>
<PasswordInput
id="password"
autoCapitalize="none"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="repeatedPassword"
render={({ field }) => (
<FormItem>
<div className="grid gap-3">
<FormLabel htmlFor="password">Repeat Passowrd</FormLabel>
<FormControl>
<Input
id="repeatPassword"
type="password"
autoCapitalize="none"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<Button type="submit" size="lg" loading={isSubmitting}>
{isSubmitting ? "Resetting Password..." : "Reset Password"}
</Button>
</div>
</form>
</Form>
</div>
</div>
);
};
+188
View File
@@ -0,0 +1,188 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { useToast } from "@/components/ui/use-toast";
import { ZCurrentPasswordSchema } from "@/trpc/routers/auth/schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { RiGoogleFill } from "@remixicon/react";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { AuthFormHeader } from "../auth-form-header";
const loginSchema = z.object({
email: z.string().email(),
password: ZCurrentPasswordSchema,
});
interface LoginFormProps {
isGoogleAuthEnabled: boolean;
}
const SignInForm = ({ isGoogleAuthEnabled }: LoginFormProps) => {
const form = useForm<z.infer<typeof loginSchema>>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: process.env.NODE_ENV === "development" ? "[email protected]" : "",
password: process.env.NODE_ENV === "development" ? "P@ssw0rd!" : "",
},
});
const { toast } = useToast();
async function onSubmit(values: z.infer<typeof loginSchema>) {
const email = values.email;
const password = values.password;
const result = await signIn("credentials", {
email,
password,
callbackUrl: "/onboarding",
});
debugger;
if (result?.error) {
toast({
variant: "destructive",
title: "Unable to sign in",
description: "Incorrect email or password",
});
}
}
async function signInWithGoogle() {
await signIn("google", { callbackUrl: "/onboarding" });
}
const isSubmitting = form.formState.isSubmitting;
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-white p-10 shadow">
<AuthFormHeader page="signin" />
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="grid gap-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<div className="grid gap-1">
<FormLabel className="sr-only" htmlFor="email">
Email
</FormLabel>
<FormControl>
<Input
id="email"
placeholder="[email protected]"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="grid gap-1">
<FormLabel className="sr-only" htmlFor="password">
Password
</FormLabel>
<FormControl>
<PasswordInput
id="password"
placeholder="*******"
autoCapitalize="none"
autoComplete="password"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<Link
href="/forgot-password"
className="text-right text-sm font-medium hover:text-gray-500"
>
Forgot your password?
</Link>
<Button
loading={isSubmitting}
loadingText="Signing in..."
type="submit"
>
Sign In
</Button>
</div>
</form>
</Form>
{isGoogleAuthEnabled && (
<>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t"></span>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button
disabled={isSubmitting}
variant="outline"
type="button"
onClick={signInWithGoogle}
>
<RiGoogleFill className="mr-2 h-4 w-4" />
Google
</Button>
</>
)}
<span className="text-center text-sm text-gray-500">
Don{`'`}t have an account?{" "}
<Link
href="/signup"
className="underline underline-offset-4 hover:text-primary"
>
Sign Up
</Link>
</span>
</>
</div>
</div>
);
};
export default SignInForm;
+219
View File
@@ -0,0 +1,219 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/trpc/react";
import { ZPasswordSchema } from "@/trpc/routers/auth/schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { RiGoogleFill } from "@remixicon/react";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { AuthFormHeader } from "../auth-form-header";
const ZSignUpFormSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: ZPasswordSchema,
});
interface SignUpFormProps {
isGoogleAuthEnabled: boolean;
}
const SignUpForm = ({ isGoogleAuthEnabled }: SignUpFormProps) => {
const form = useForm<z.infer<typeof ZSignUpFormSchema>>({
resolver: zodResolver(ZSignUpFormSchema),
defaultValues: {
name: "",
email: "",
password: "",
},
});
const router = useRouter();
const { mutateAsync } = api.auth.signup.useMutation({
onSuccess: async ({ message }) => {
toast({
variant: "default",
title: "🎉 Thanks for signing up!",
description: message,
});
},
onError: (err) => {
toast({
variant: "destructive",
title: "Uh oh! Something went wrong.",
description: err.message,
});
},
});
async function onSubmit(values: z.infer<typeof ZSignUpFormSchema>) {
try {
await mutateAsync(values);
router.replace(`/check-email?email=${values.email}`);
} catch (err) {
console.error(err);
}
}
async function signInWithGoogle() {
await signIn("google", { callbackUrl: "/onboarding" });
}
const isSubmitting = form.formState.isSubmitting;
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-white p-10 shadow">
<AuthFormHeader page="signup" />
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="grid gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<div className="grid gap-1">
<FormLabel className="sr-only" htmlFor="name">
Full name
</FormLabel>
<FormControl>
<Input
id="fullname"
placeholder="Full Name"
type="text"
autoCapitalize="none"
autoComplete="name"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<div className="grid gap-1">
<FormLabel className="sr-only" htmlFor="email">
Email
</FormLabel>
<FormControl>
<Input
id="email"
placeholder="[email protected]"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="grid gap-1">
<FormLabel className="sr-only" htmlFor="password">
Password
</FormLabel>
<FormControl>
<PasswordInput
id="password"
placeholder="*******"
autoCapitalize="none"
autoComplete="password"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<Button
loading={isSubmitting}
loadingText="Signing up..."
type="submit"
>
Sign Up
</Button>
</div>
</form>
</Form>
{isGoogleAuthEnabled && (
<>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t"></span>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button
disabled={isSubmitting}
variant="outline"
type="button"
onClick={signInWithGoogle}
>
<RiGoogleFill className="mr-2 h-4 w-4" />
Google
</Button>
</>
)}
<span className="text-center text-sm text-gray-500">
Already have an account?{" "}
<Link
href="/signin"
className="underline underline-offset-4 hover:text-primary "
>
Sign In
</Link>
</span>
</>
</div>
</div>
);
};
export default SignUpForm;
@@ -0,0 +1,116 @@
"use client";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import {
RiArrowRightLine,
RiMailCheckLine,
RiMailCloseLine,
} from "@remixicon/react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
const VerifyEmail = ({ token }: { token: string }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | undefined>("");
const [success, setSuccess] = useState<string | undefined>("");
const { mutateAsync } = api.auth.verifyEmail.useMutation({
onSuccess: async ({ message }) => {
setLoading(false);
setSuccess(message);
},
onError: (error) => {
setLoading(false);
setError(error.message);
},
});
const onSubmit = useCallback(async () => {
if (success || error) return;
if (!token) {
setLoading(false);
setError("Missing token!");
return;
}
try {
await mutateAsync(token);
} catch (err) {
console.error(err);
setLoading(false);
setError("Something went wrong! Please try again.");
}
}, [token, success, error]);
useEffect(() => {
void onSubmit();
}, [onSubmit]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-white p-10 shadow">
<div className="flex flex-col gap-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Verifying...
</h1>
</div>
</div>
</div>
);
}
return (
<div className="flex h-screen items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-cyan-100">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-white p-10 shadow">
<div className="flex flex-col gap-y-2 text-center">
{success ? (
<>
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-teal-100">
<span className="text-teal-500">
<RiMailCheckLine className="h-6 w-auto" />
</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight">
{success}
</h1>
<div className="mb-2 text-center text-sm text-muted-foreground">
Your account has been verified. Please sign in to continue.
</div>
</>
) : (
<>
<RiMailCloseLine className="mb-1 h-10 w-auto" />
<h1 className="text-2xl font-semibold tracking-tight">
Verification Failed
</h1>
<div className="mb-2 text-center text-sm text-muted-foreground">
{error}
</div>
</>
)}
{success ? (
<Link href="/signin" className="mt-4">
<Button size="lg">
Continue to Sign in page
<RiArrowRightLine className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
<Link href="/signup" className="mt-4">
<Button size="lg">
Try signing up again
<RiArrowRightLine className="ml-2 h-4 w-4" />
</Button>
</Link>
)}
</div>
</div>
</div>
);
};
export default VerifyEmail;
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
import { RiEyeLine, RiEyeOffLine } from "@remixicon/react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import { Input, type InputProps } from "./input";
const PasswordInput = React.forwardRef<
HTMLInputElement,
Omit<InputProps, "type">
>(({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
className={cn("pr-10", className)}
ref={ref}
{...props}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showPassword ? "Mask password" : "Reveal password"}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? (
<RiEyeOffLine aria-hidden className="h-5 w-5 text-muted-foreground" />
) : (
<RiEyeLine aria-hidden className="h-5 w-5 text-muted-foreground" />
)}
</Button>
</div>
);
});
PasswordInput.displayName = "PasswordInput";
export { PasswordInput };
+65
View File
@@ -0,0 +1,65 @@
import { constants } from "../lib/constants";
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "jsx-email";
import * as React from "react";
interface AccountVerificationEmailProps {
verifyLink: string;
}
export const AccountVerificationEmail = ({
verifyLink,
}: AccountVerificationEmailProps) => (
<Html>
<Head />
<Preview>Your email verification link for {constants.title}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-[40px] w-[465px] border-separate rounded border border-solid border-neutral-200 p-[20px]">
<Heading className="mx-0 my-[30px] p-0 text-[24px] font-normal text-black">
Your verification email link for {constants.title}
</Heading>
<Section>
<Section className="mb-[5px] mt-[10px] ">
<Button
className="rounded bg-black px-5 py-3 text-center text-[12px] font-semibold text-white no-underline"
href={verifyLink}
>
Verify
</Button>
</Section>
</Section>
<Text className="!text-[14px] leading-[24px] text-black">
or copy and paste this URL into your browser:{" "}
<Link
href={verifyLink}
className="break-all text-blue-600 no-underline"
>
{verifyLink}
</Link>
</Text>
<Hr className="mx-0 my-[26px] w-full border border-solid border-neutral-200" />
<Link
href={constants.url}
className="text-sm !text-gray-400 no-underline"
>
{constants.title}
</Link>
</Container>
</Body>
</Tailwind>
</Html>
);
export default AccountVerificationEmail;
+63
View File
@@ -0,0 +1,63 @@
import { constants } from "../lib/constants";
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "jsx-email";
import * as React from "react";
interface PasswordResetEmailProps {
resetLink: string;
}
export const PasswordResetEmail = ({ resetLink }: PasswordResetEmailProps) => (
<Html>
<Head />
<Preview>Your password reset link for {constants.title}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-[40px] w-[465px] border-separate rounded border border-solid border-neutral-200 p-[20px]">
<Heading className="mx-0 my-[30px] p-0 text-[24px] font-normal text-black">
Your password reset link for {constants.title}
</Heading>
<Section>
<Section className="mb-[5px] mt-[10px] ">
<Button
className="rounded bg-black px-5 py-3 text-center text-[12px] font-semibold text-white no-underline"
href={resetLink}
>
Reset
</Button>
</Section>
</Section>
<Text className="!text-[14px] leading-[24px] text-black">
or copy and paste this URL into your browser:{" "}
<Link
href={resetLink}
className="break-all text-blue-600 no-underline"
>
{resetLink}
</Link>
</Text>
<Hr className="mx-0 my-[26px] w-full border border-solid border-neutral-200" />
<Link
href={constants.url}
className="text-sm !text-gray-400 no-underline"
>
{constants.title}
</Link>
</Container>
</Body>
</Tailwind>
</Html>
);
export default PasswordResetEmail;
+38
View File
@@ -0,0 +1,38 @@
import { render } from "jsx-email";
import AccountVerificationEmail from "@/emails/AccountVerificationEmail";
import PasswordResetEmail from "@/emails/PasswordResetEmail";
import { sendMail } from "@/server/mailer";
export const sendVerificationEmail = async (email: string, token: string) => {
const baseUrl = process.env.NEXTAUTH_URL;
const confirmLink = `${baseUrl}/verify-email/${token}`;
const html = await render(
AccountVerificationEmail({
verifyLink: confirmLink,
}),
);
await sendMail({
to: email,
subject: "Confirm your email",
html,
});
};
export const sendPasswordResetEmail = async (email: string, token: string) => {
const baseUrl = process.env.NEXTAUTH_URL;
const confirmLink = `${baseUrl}/reset-password/${token}`;
const html = await render(
PasswordResetEmail({
resetLink: confirmLink,
}),
);
await sendMail({
to: email,
subject: "Reset your password",
html,
});
};
+55
View File
@@ -0,0 +1,55 @@
import { nanoid } from "nanoid";
import { db } from "@/server/db";
import { getVerificationTokenByEmail } from "@/server/verification-token";
import { getPasswordResetokenByEmail } from "@/server/password-reset-token";
export const generateVerificationToken = async (email: string) => {
const token = nanoid(32);
const expires = new Date(new Date().getTime() + 3600 * 1000);
const existingToken = await getVerificationTokenByEmail(email);
if (existingToken) {
await db.verificationToken.delete({
where: {
id: existingToken.id,
},
});
}
const verificationToken = await db.verificationToken.create({
data: {
identifier: email,
token,
expires,
},
});
return verificationToken;
};
export const generatePasswordResetToken = async (email: string) => {
const token = nanoid(32);
const expires = new Date(new Date().getTime() + 3600 * 1000);
const existingToken = await getPasswordResetokenByEmail(email);
if (existingToken) {
await db.passwordResetToken.delete({
where: {
id: existingToken.id,
},
});
}
const passwordResetToken = await db.passwordResetToken.create({
data: {
email,
token,
expires,
},
});
return passwordResetToken;
};
+38 -20
View File
@@ -1,21 +1,20 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { render } from "jsx-email";
import bcrypt from "bcryptjs";
import {
getServerSession,
type DefaultSession,
type NextAuthOptions,
} from "next-auth";
import EmailProvider from "next-auth/providers/email";
import CredentialsProvider from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google";
import MagicLinkEmail from "@/emails/MagicLinkEmail";
import { env } from "@/env";
import { type MemberStatusEnum } from "@/prisma/enums";
import { db } from "@/server/db";
import { sendMail } from "./mailer";
import { getUserByEmail, getUserById } from "./user";
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID!;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET!;
@@ -56,7 +55,23 @@ declare module "next-auth/jwt" {
* @see https://next-auth.js.org/configuration/options
*/
export const authOptions: NextAuthOptions = {
events: {
async linkAccount({ user }) {
await db.user.update({
where: { id: user.id },
data: { emailVerified: new Date() },
});
},
},
callbacks: {
async signIn({ user, account }) {
if (account?.provider !== "credentials") return true;
const existingUser = await getUserById(user.id);
if (!existingUser?.emailVerified) return false;
return true;
},
session({ session, token }) {
session.user.isOnboarded = token.isOnboarded;
session.user.companyId = token.companyId;
@@ -129,23 +144,26 @@ export const authOptions: NextAuthOptions = {
strategy: "jwt",
},
providers: [
EmailProvider({
sendVerificationRequest: async ({ identifier, url }) => {
if (env.NODE_ENV === "development") {
console.log(`🔑 Login link: ${url}`);
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (credentials) {
const { email, password } = credentials;
const user = await getUserByEmail(email);
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (!user || !user.password) return null;
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const passwordsMatch = await bcrypt.compare(password, user.password);
if (passwordsMatch) return user;
}
const html = await render(
MagicLinkEmail({
magicLink: url,
}),
);
await sendMail({
to: identifier,
subject: "Your Captable, Inc. Login Link",
html,
});
return null;
},
}),
/**
+25
View File
@@ -0,0 +1,25 @@
import { db } from "./db";
export const getPasswordResetTokenByToken = async (token: string) => {
try {
const passwordResetToken = await db.passwordResetToken.findUnique({
where: { token },
});
return passwordResetToken;
} catch {
return null;
}
};
export const getPasswordResetokenByEmail = async (email: string) => {
try {
const passwordResetToken = await db.passwordResetToken.findFirst({
where: { email },
});
return passwordResetToken;
} catch {
return null;
}
};
+20
View File
@@ -0,0 +1,20 @@
import { db } from "./db";
export const getUserByEmail = async (email: string) => {
try {
const user = await db.user.findUnique({ where: { email } });
return user;
} catch {
return null;
}
};
export const getUserById = async (id: string) => {
try {
const user = await db.user.findUnique({ where: { id } });
return user;
} catch {
return null;
}
};
+25
View File
@@ -0,0 +1,25 @@
import { db } from "./db";
export const getVerificationTokenByToken = async (token: string) => {
try {
const verificationToken = await db.verificationToken.findUnique({
where: { token },
});
return verificationToken;
} catch {
return null;
}
};
export const getVerificationTokenByEmail = async (email: string) => {
try {
const verificationToken = await db.verificationToken.findFirst({
where: { identifier: email },
});
return verificationToken;
} catch {
return null;
}
};
+2
View File
@@ -15,6 +15,7 @@ import { stakeholderRouter } from "../routers/stakeholder-router/router";
import { templateFieldRouter } from "../routers/template-field-router/router";
import { templateRouter } from "../routers/template-router/router";
import { updateRouter } from "../routers/update/router";
import { authRouter } from "../routers/auth/router";
/**
* This is the primary router for your server.
@@ -37,6 +38,7 @@ export const appRouter = createTRPCRouter({
securities: securitiesRouter,
safe: safeRouter,
update: updateRouter,
auth: authRouter,
dataRoom: dataRoomRouter,
});
@@ -0,0 +1,30 @@
import { publicProcedure } from "@/trpc/api/trpc";
import { z } from "zod";
import { getUserByEmail } from "@/server/user";
import { TRPCError } from "@trpc/server";
import { generatePasswordResetToken } from "@/lib/token";
import { sendPasswordResetEmail } from "@/lib/mail";
export const forgotPasswordProcedure = publicProcedure
.input(z.string().email())
.mutation(async ({ input }) => {
const existingUser = await getUserByEmail(input);
if (!existingUser) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email not found!",
});
}
const passwordResetToken = await generatePasswordResetToken(input);
await sendPasswordResetEmail(
passwordResetToken.email,
passwordResetToken.token,
);
return {
success: true,
message:
"To reset your password, please click the link sent to your email.",
};
});
@@ -0,0 +1,54 @@
import { publicProcedure } from "@/trpc/api/trpc";
import { ZNewPasswordProcedureSchema } from "../schema";
import { getPasswordResetTokenByToken } from "@/server/password-reset-token";
import { TRPCError } from "@trpc/server";
import { getUserByEmail } from "@/server/user";
import bcrypt from "bcryptjs";
export const newPasswordProcedure = publicProcedure
.input(ZNewPasswordProcedureSchema)
.mutation(async ({ ctx, input }) => {
const { token, password } = input;
const existingToken = await getPasswordResetTokenByToken(token);
if (!existingToken) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid token!",
});
}
const hasExpired = new Date(existingToken.expires) < new Date();
if (hasExpired) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Token expired!",
});
}
const existingUser = await getUserByEmail(existingToken.email);
if (!existingUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email not found!",
});
}
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
await ctx.db.user.update({
where: { id: existingUser.id },
data: {
password: hashedPassword,
},
});
await ctx.db.passwordResetToken.delete({
where: { id: existingToken.id },
});
return {
success: true,
message: "Your password has been updated successfully.",
};
});
@@ -0,0 +1,29 @@
import { sendVerificationEmail } from "@/lib/mail";
import { generateVerificationToken } from "@/lib/token";
import { getVerificationTokenByEmail } from "@/server/verification-token";
import { publicProcedure } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
export const resendEmailProcedure = publicProcedure
.input(z.string().email())
.mutation(async ({ input }) => {
const oldVerificationToken = await getVerificationTokenByEmail(input);
if (!oldVerificationToken) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email not found!",
});
}
const verificationToken = await generateVerificationToken(input);
await sendVerificationEmail(
verificationToken.identifier,
verificationToken.token,
);
return {
success: true,
message:
"To verify your account, please click the verification link sent to your email.",
};
});
+45
View File
@@ -0,0 +1,45 @@
import { sendVerificationEmail } from "@/lib/mail";
import { generateVerificationToken } from "@/lib/token";
import { publicProcedure } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs";
import { ZSignUpMutationSchema } from "../schema";
export const signupProcedure = publicProcedure
.input(ZSignUpMutationSchema)
.mutation(async ({ ctx, input }) => {
const { name, email, password } = input;
const userExists = await ctx.db.user.findFirst({
where: {
email: email.toLowerCase(),
},
});
if (userExists) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User already exists",
});
}
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
await ctx.db.user.create({
data: {
name,
email,
password: hashedPassword,
},
});
const verificationToken = await generateVerificationToken(email);
await sendVerificationEmail(
verificationToken.identifier,
verificationToken.token,
);
return {
success: true,
message: "Please check your email to verify your account.",
};
});
@@ -0,0 +1,51 @@
import { getUserByEmail } from "@/server/user";
import { getVerificationTokenByToken } from "@/server/verification-token";
import { publicProcedure } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
export const verifyEmailProcedure = publicProcedure
.input(z.string())
.mutation(async ({ ctx, input }) => {
const existingToken = await getVerificationTokenByToken(input);
if (!existingToken) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Token does not exists!",
});
}
const hasExpired = new Date(existingToken.expires) < new Date();
if (hasExpired) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Token expired!",
});
}
const existingUser = await getUserByEmail(existingToken.identifier);
if (!existingUser) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email does not exists!",
});
}
await ctx.db.user.update({
where: { id: existingUser.id },
data: {
emailVerified: new Date(),
email: existingToken.identifier,
},
});
await ctx.db.verificationToken.delete({
where: { id: existingToken.id },
});
return {
success: true,
message: "You're all set!",
};
});
+13
View File
@@ -0,0 +1,13 @@
import { createTRPCRouter } from "@/trpc/api/trpc";
import { signupProcedure } from "./procedure/signup";
import { resendEmailProcedure } from "./procedure/resend-email";
import { verifyEmailProcedure } from "./procedure/verify-email";
import { forgotPasswordProcedure } from "./procedure/forgot-password";
import { newPasswordProcedure } from "./procedure/new-password";
export const authRouter = createTRPCRouter({
signup: signupProcedure,
resendEmail: resendEmailProcedure,
verifyEmail: verifyEmailProcedure,
forgotPassword: forgotPasswordProcedure,
newPassword: newPasswordProcedure,
});
+27
View File
@@ -0,0 +1,27 @@
import { z } from "zod";
export const ZCurrentPasswordSchema = z
.string()
.min(6, { message: "Must be at least 6 characters in length" })
.max(72);
export const ZPasswordSchema = z
.string()
.regex(new RegExp(".*[A-Z].*"), { message: "One uppercase character" })
.regex(new RegExp(".*[a-z].*"), { message: "One lowercase character" })
.regex(new RegExp(".*\\d.*"), { message: "One number" })
.regex(new RegExp(".*[`~<>?,./!@#$%^&*()\\-_+=\"'|{}\\[\\];:\\\\].*"), {
message: "One special character is required",
})
.min(8, { message: "Must be at least 8 characters in length" })
.max(72, { message: "Cannot be more than 72 characters in length" });
export const ZSignUpMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: ZPasswordSchema,
});
export const ZNewPasswordProcedureSchema = z.object({
token: z.string(),
password: z.string(),
});
+3 -1
View File
@@ -37,7 +37,9 @@
"**/*.tsx",
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
".next/types/**/*.ts",
"src/components/onboarding/verify-email",
"src/app/(unauthenticated)/forgot-password"
],
"exclude": ["node_modules"]
}