diff --git a/bun.lockb b/bun.lockb index 0eb9a911..d647668a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index ebf8a1ad..ccd0ccb1 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dependencies": { "@edge-runtime/cookies": "^4.1.1", "@headlessui/react": "^1.7.17", + "@hookform/resolvers": "^3.3.4", "@react-email/render": "^0.0.10", "@supabase/ssr": "^0.3.0", "@supabase/supabase-js": "^2.42.0", diff --git a/src/components/AuthForm.tsx b/src/components/AuthForm.tsx new file mode 100644 index 00000000..e8b7c80f --- /dev/null +++ b/src/components/AuthForm.tsx @@ -0,0 +1,100 @@ +import { useForm } from "react-hook-form"; +import { api } from "~/utils/api"; +import { FaGoogle } from "react-icons/fa"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import LoadingSpinner from "~/components/LoadingSpinner"; + +interface FormValues { + email: string; +} + +interface AuthProps { + setIsMagicLinkSent: (value: boolean, recipient: string) => void; +} + +const EmailSchema = z.object({ email: z.string().email() }); + +export function Auth({ setIsMagicLinkSent }: AuthProps) { + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(EmailSchema), // Apply the zodResolver + }); + + const email = watch("email"); + + const loginWithEmail = api.auth.loginWithEmail.useMutation({ + onSuccess: () => { + setIsMagicLinkSent(true, email); + }, + }); + + const loginWithOAuth = api.auth.loginWithOAuth.useMutation({ + onSuccess: (data) => { + if (data?.url) window.open(data.url); + }, + }); + + const onSubmit = (values: FormValues) => { + try { + loginWithEmail.mutate({ + email: values.email, + }); + } catch (error) { + console.error(error); + } + }; + + return ( +
+
+ +
+
+
+ + {!loginWithEmail.error && !loginWithOAuth.error && errors.email && ( +

+ Please enter a valid email address +

+ )} + {loginWithEmail.error ?? loginWithOAuth.error ? ( +

+ Something went wrong, please try again later or contact customer + support. +

+ ) : null} + +
+ +
+ +
+ ); +} diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx new file mode 100644 index 00000000..4ad32246 --- /dev/null +++ b/src/components/LoadingSpinner.tsx @@ -0,0 +1,26 @@ +const LoadingSpinner = () => { + return ( + + + + + ); +}; + +export default LoadingSpinner; diff --git a/src/components/auth.tsx b/src/components/auth.tsx deleted file mode 100644 index bd065c1b..00000000 --- a/src/components/auth.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Formik, Form, Field } from "formik"; -import { api } from "~/utils/api"; -import { useRouter } from "next/navigation"; - -interface FormValues { - email: string; -} - -export function Login() { - const router = useRouter(); - - const login = api.auth.login.useMutation({ - onSuccess: () => { - router.push("/verify"); - }, - }); - - return ( - { - login.mutate({ - email: values.email, - }); - }} - > -
-
- -
- -
-
- -
- -
-
-
- ); -} diff --git a/src/pages/api/auth/confirm.ts b/src/pages/api/auth/confirm.ts index c913d1e8..fa24d231 100644 --- a/src/pages/api/auth/confirm.ts +++ b/src/pages/api/auth/confirm.ts @@ -19,35 +19,43 @@ export default async function handler( const queryParams = req.query; const token_hash = stringOrFirstString(queryParams.token_hash); const type = stringOrFirstString(queryParams.type); + const code = stringOrFirstString(queryParams.code); let next = "/error"; - if (token_hash && type) { + let authRes; + + if ((token_hash && type) ?? code) { const db = createNextClient(req, res); - const { error, data } = await db.auth.verifyOtp({ - type: type as EmailOtpType, - token_hash, - }); - console.log({ error }); + if (token_hash && type) { + authRes = await db.auth.verifyOtp({ + type: type as EmailOtpType, + token_hash, + }); + } - if (data.user?.id) { - const user = await db + if (code) { + authRes = await db.auth.exchangeCodeForSession(code); + } + + const user = authRes?.data.user; + + if (user?.id) { + const existingUser = await db .from("user") .select() - .eq("id", data.user.id) + .eq("id", user.id) .limit(1) .maybeSingle(); - if (!user.data) { - await db - .from("user") - .insert({ id: data.user.id, email: data.user.email ?? "" }); + if (!existingUser.data) { + await db.from("user").insert({ id: user.id, email: user.email ?? "" }); } } - if (error) { - console.error(error); + if (authRes?.error) { + console.error(authRes.error); } else { next = stringOrFirstString(queryParams.next) ?? "/"; } diff --git a/src/pages/signup/index.tsx b/src/pages/signup/index.tsx new file mode 100644 index 00000000..152a9774 --- /dev/null +++ b/src/pages/signup/index.tsx @@ -0,0 +1,5 @@ +import SignupView from "~/views/auth/signup"; + +export default function SignupPage() { + return ; +} diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx deleted file mode 100644 index 6cfa6c29..00000000 --- a/src/pages/verify/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import VerifyView from "~/views/auth/verify"; - -export default function VerifyPage() { - return ; -} diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index 0e540a0a..7aaad0d3 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -21,7 +21,7 @@ export const authRouter = createTRPCRouter({ return data; }), - login: publicProcedure + loginWithEmail: publicProcedure .input(z.object({ email: z.string() })) .mutation(async ({ ctx, input }) => { const { data } = await ctx.db.auth.signInWithOtp({ @@ -31,6 +31,24 @@ export const authRouter = createTRPCRouter({ }, }); + return data; + }), + loginWithOAuth: publicProcedure + .input(z.object({ provider: z.string() })) + .mutation(async ({ ctx, input }) => { + if (input.provider !== "google") return null; + + const { data } = await ctx.db.auth.signInWithOAuth({ + provider: "google", + options: { + queryParams: { + access_type: "offline", + prompt: "consent", + }, + redirectTo: `${env.WEBSITE_URL}/api/auth/confirm`, + }, + }); + return data; }), }); diff --git a/src/views/auth/login/index.tsx b/src/views/auth/login/index.tsx index d1226beb..a4335399 100644 --- a/src/views/auth/login/index.tsx +++ b/src/views/auth/login/index.tsx @@ -1,20 +1,51 @@ -import { Login } from "~/components/auth"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Auth } from "~/components/AuthForm"; +import { api } from "~/utils/api"; export default function LoginPage() { + const router = useRouter(); + const [isMagicLinkSent, setIsMagicLinkSent] = useState(false); + const [magicLinkRecipient, setMagicLinkRecipient] = useState(""); + + const handleMagicLinkSent = (value: boolean, recipient: string) => { + setIsMagicLinkSent(value); + setMagicLinkRecipient(recipient); + }; + + const authCookieExists = document.cookie + .split("; ") + .some((cookie) => cookie.includes("auth-token")); + + if (authCookieExists) { + const { error } = api.auth.getUser.useQuery(); + + if (!error) router.push("/boards"); + } + return ( -
-

- kan.bn -

-

Get started

-
-
- -
+
+
+

+ kan.bn +

+

+ {isMagicLinkSent ? "Check your inbox" : "Welcome back"} +

+ {isMagicLinkSent ? ( +
+

+ {`Click on the link we've sent to ${magicLinkRecipient} to sign in.`} +

+
+ ) : ( +
+
+ +
+
+ )}
-

- {"Already have an account?"} -

); } diff --git a/src/views/auth/signup/index.tsx b/src/views/auth/signup/index.tsx new file mode 100644 index 00000000..5ad80de0 --- /dev/null +++ b/src/views/auth/signup/index.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Auth } from "~/components/AuthForm"; +import { api } from "~/utils/api"; + +export default function SignupPage() { + const router = useRouter(); + const [isMagicLinkSent, setIsMagicLinkSent] = useState(false); + const [magicLinkRecipient, setMagicLinkRecipient] = useState(""); + + const handleMagicLinkSent = (value: boolean, recipient: string) => { + setIsMagicLinkSent(value); + setMagicLinkRecipient(recipient); + }; + + const authCookieExists = document.cookie + .split("; ") + .some((cookie) => cookie.includes("auth-token")); + + if (authCookieExists) { + const { error } = api.auth.getUser.useQuery(); + + if (!error) router.push("/boards"); + } + + return ( +
+
+

+ kan.bn +

+

+ {isMagicLinkSent ? "Check your inbox" : "Get started"} +

+ {isMagicLinkSent ? ( +
+

+ {`Click on the link we've sent to ${magicLinkRecipient} to sign in.`} +

+
+ ) : ( +
+
+ +
+
+ )} +
+
+ ); +} diff --git a/src/views/auth/verify/index.tsx b/src/views/auth/verify/index.tsx deleted file mode 100644 index 33e9a9c2..00000000 --- a/src/views/auth/verify/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export default function VerifyPage() { - return ( -
-

Check your inbox

-
-

- {`Click on the link we've sent to you@example.com to sign in.`} -

-
-
- ); -}