mirror of
https://github.com/hanzoai/captable.git
synced 2026-10-16 14:21:29 +00:00
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:
committed by
GitHub
co-authored by
Puru D
parent
6ccd207595
commit
8d5a8e87ca
Generated
+12
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -8,5 +8,5 @@ export default async function HomePage() {
|
||||
return redirect(`/${session.user.companyPublicId}`);
|
||||
}
|
||||
|
||||
return redirect("/login");
|
||||
return redirect("/signin");
|
||||
}
|
||||
|
||||
@@ -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'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'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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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.",
|
||||
};
|
||||
});
|
||||
@@ -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!",
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user