From 3a462fb50b69c5d76e02e1d3219fa5a231cf50c1 Mon Sep 17 00:00:00 2001 From: KMKoushik Date: Sun, 28 Apr 2024 18:55:04 +1000 Subject: [PATCH] Add login flow --- apps/web/package.json | 2 + apps/web/src/app/(dashboard)/layout.tsx | 3 - apps/web/src/app/layout.tsx | 7 +- apps/web/src/app/login/login-page.tsx | 202 ++++++++++++++++++++++++ apps/web/src/app/login/page.tsx | 13 ++ apps/web/src/env.js | 8 + apps/web/src/server/auth.ts | 21 +++ apps/web/src/server/mailer.ts | 57 +++++++ packages/ui/package.json | 6 +- packages/ui/src/form.tsx | 177 +++++++++++++++++++++ packages/ui/src/input-otp.tsx | 79 +++++++++ pnpm-lock.yaml | 45 ++++++ turbo.json | 6 +- 13 files changed, 618 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/app/login/login-page.tsx create mode 100644 apps/web/src/app/login/page.tsx create mode 100644 apps/web/src/server/mailer.ts create mode 100644 packages/ui/src/form.tsx create mode 100644 packages/ui/src/input-otp.tsx diff --git a/apps/web/package.json b/apps/web/package.json index f4640d7..2d9e297 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@hono/node-server": "^1.9.1", "@hono/swagger-ui": "^0.2.1", "@hono/zod-openapi": "^0.10.0", + "@hookform/resolvers": "^3.3.4", "@prisma/client": "^5.11.0", "@t3-oss/env-nextjs": "^0.9.2", "@tanstack/react-query": "^5.25.0", @@ -42,6 +43,7 @@ "query-string": "^9.0.0", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.51.3", "recharts": "^2.12.5", "server-only": "^0.0.1", "superjson": "^2.2.1", diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 0bb6d49..c5b2b0a 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -1,7 +1,6 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { - BellRing, BookUser, CircleUser, Code, @@ -11,7 +10,6 @@ import { LineChart, Mail, Menu, - MessageSquareMore, Package, Package2, ShoppingCart, @@ -32,7 +30,6 @@ import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet"; import { NextAuthProvider } from "~/providers/next-auth"; import { getServerAuthSession } from "~/server/auth"; -import Image from "next/image"; import { NavButton } from "./nav-button"; export const metadata = { diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index ed96318..08112ce 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -5,15 +5,16 @@ import { ThemeProvider } from "@unsend/ui/theme-provider"; import { Toaster } from "@unsend/ui/src/toaster"; import { TRPCReactProvider } from "~/trpc/react"; +import { Metadata } from "next"; const inter = Inter({ subsets: ["latin"], variable: "--font-sans", }); -export const metadata = { - title: "Create T3 App", - description: "Generated by create-t3-app", +export const metadata: Metadata = { + title: "Unsend", + description: "Open source sending infrastructure for developers", icons: [{ rel: "icon", url: "/favicon.ico" }], }; diff --git a/apps/web/src/app/login/login-page.tsx b/apps/web/src/app/login/login-page.tsx new file mode 100644 index 0000000..30ac27a --- /dev/null +++ b/apps/web/src/app/login/login-page.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { Button } from "@unsend/ui/src/button"; +import Image from "next/image"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormMessage, +} from "@unsend/ui/src/form"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, + REGEXP_ONLY_DIGITS_AND_CHARS, +} from "@unsend/ui/src/input-otp"; +import { Input } from "@unsend/ui/src/input"; + +const emailSchema = z.object({ + email: z + .string({ required_error: "Email is required" }) + .email({ message: "Invalid email" }), +}); + +const otpSchema = z.object({ + otp: z + .string({ required_error: "OTP is required" }) + .length(5, { message: "Invalid OTP" }), +}); + +export default function LoginPage() { + const [emailStatus, setEmailStatus] = useState< + "idle" | "sending" | "success" + >("idle"); + + const emailForm = useForm>({ + resolver: zodResolver(emailSchema), + }); + + const otpForm = useForm>({ + resolver: zodResolver(otpSchema), + }); + + async function onEmailSubmit(values: z.infer) { + setEmailStatus("sending"); + await signIn("email", { + email: values.email.toLowerCase(), + redirect: false, + }); + setEmailStatus("success"); + } + + async function onOTPSubmit(values: z.infer) { + const { href: callbackUrl } = window.location; + const email = emailForm.getValues().email; + console.log("email", email); + + window.location.href = `/api/auth/callback/email?email=${encodeURIComponent( + email.toLowerCase() + )}&token=${values.otp.toLowerCase()}${callbackUrl ? `&callbackUrl=${callbackUrl}/balances` : ""}`; + } + + return ( +
+
+ Unsend +

Log in to unsend

+
+ + +
+

+ or +

+
+
+ {emailStatus === "success" ? ( + <> +

+ We have sent an email with the OTP. Please check your inbox +

+
+ + ( + + + + + + + + + + + + + + + + )} + /> + + + + + + ) : ( + <> +
+ + ( + + + + + + + + )} + /> + + + + + )} +
+
+
+ ); +} diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx new file mode 100644 index 0000000..ede716e --- /dev/null +++ b/apps/web/src/app/login/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; +import { getServerAuthSession } from "~/server/auth"; +import LoginPage from "./login-page"; + +export default async function Login() { + const session = await getServerAuthSession(); + + if (session) { + redirect("/dashboard"); + } + + return ; +} diff --git a/apps/web/src/env.js b/apps/web/src/env.js index d1867c0..1b256b8 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -34,6 +34,10 @@ export const env = createEnv({ AWS_SECRET_KEY: z.string(), APP_URL: z.string().optional(), SNS_TOPIC: z.string(), + UNSEND_API_KEY: z.string(), + UNSEND_URL: z.string(), + GOOGLE_CLIENT_ID: z.string(), + GOOGLE_CLIENT_SECRET: z.string(), }, /** @@ -60,6 +64,10 @@ export const env = createEnv({ AWS_SECRET_KEY: process.env.AWS_SECRET_KEY, APP_URL: process.env.APP_URL, SNS_TOPIC: process.env.SNS_TOPIC, + UNSEND_API_KEY: process.env.UNSEND_API_KEY, + UNSEND_URL: process.env.UNSEND_URL, + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 2d7826a..122ade4 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -6,6 +6,8 @@ import { } from "next-auth"; import { type Adapter } from "next-auth/adapters"; import GitHubProvider from "next-auth/providers/github"; +import EmailProvider from "next-auth/providers/email"; +import GoogleProvider from "next-auth/providers/google"; import { env } from "~/env"; import { db } from "~/server/db"; @@ -48,6 +50,9 @@ export const authOptions: NextAuthOptions = { }), }, adapter: PrismaAdapter(db) as Adapter, + pages: { + signIn: "/login", + }, providers: [ /** * ...add more providers here. @@ -61,6 +66,21 @@ export const authOptions: NextAuthOptions = { GitHubProvider({ clientId: env.GITHUB_ID, clientSecret: env.GITHUB_SECRET, + allowDangerousEmailAccountLinking: true, + }), + GoogleProvider({ + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + allowDangerousEmailAccountLinking: true, + }), + EmailProvider({ + from: "no-reply@splitpro.app", + async sendVerificationRequest({ identifier: email, url, token }) { + await sendSignUpEmail(email, token, url); + }, + async generateVerificationToken() { + return Math.random().toString(36).substring(2, 7).toLowerCase(); + }, }), ], }; @@ -73,6 +93,7 @@ export const authOptions: NextAuthOptions = { export const getServerAuthSession = () => getServerSession(authOptions); import { createHash } from "crypto"; +import { sendSignUpEmail } from "./mailer"; /** * Hashes a token using SHA-256. diff --git a/apps/web/src/server/mailer.ts b/apps/web/src/server/mailer.ts new file mode 100644 index 0000000..fa9107d --- /dev/null +++ b/apps/web/src/server/mailer.ts @@ -0,0 +1,57 @@ +import { env } from "~/env"; + +export async function sendSignUpEmail( + email: string, + token: string, + url: string +) { + const { host } = new URL(url); + + if (env.NODE_ENV === "development") { + console.log("Sending sign in email", email, url, token); + return; + } + + const subject = "Sign in to Unsend"; + const text = `Hey,\n\nYou can sign in to Unsend by clicking the below URL:\n${url}\n\nYou can also use this OTP: ${token}\n\nThanks,\nUnsend Team`; + const html = `

Hey,

You can sign in to Unsend by clicking the below URL:

Sign in to ${host}

You can also use this OTP: ${token}

Thanks,

Unsend Team

`; + + await sendMail(email, subject, text, html); +} + +async function sendMail( + email: string, + subject: string, + text: string, + html: string +) { + if (env.UNSEND_API_KEY && env.UNSEND_URL) { + const resp = await fetch(`${env.UNSEND_URL}/emails`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.UNSEND_API_KEY}`, + }, + body: JSON.stringify({ + from: "no-reply@auth.unsend.dev", + to: email, + subject, + text, + html, + }), + }); + + if (resp.status === 200) { + console.log("Email sent using unsend"); + return; + } else { + console.log( + "Error sending email using unsend, so fallback to resend", + resp.status, + resp.statusText + ); + } + } else { + throw new Error("UNSEND_API_KEY or UNSEND_URL not found"); + } +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 7f860cb..337fff3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -29,6 +29,7 @@ "typescript": "^5.3.3" }, "dependencies": { + "@hookform/resolvers": "^3.3.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", @@ -40,12 +41,15 @@ "add": "^2.0.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "input-otp": "^1.2.4", "lucide-react": "^0.359.0", "next-themes": "^0.3.0", "pnpm": "^8.15.5", + "react-hook-form": "^7.51.3", "react-syntax-highlighter": "^15.5.0", "sonner": "^1.4.41", "tailwind-merge": "^2.2.2", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.22.4" } } \ No newline at end of file diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx new file mode 100644 index 0000000..d14096f --- /dev/null +++ b/packages/ui/src/form.tsx @@ -0,0 +1,177 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "../lib/utils"; +import { Label } from "./label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +