feat: google oauth
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edge-runtime/cookies": "^4.1.1",
|
"@edge-runtime/cookies": "^4.1.1",
|
||||||
"@headlessui/react": "^1.7.17",
|
"@headlessui/react": "^1.7.17",
|
||||||
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@react-email/render": "^0.0.10",
|
"@react-email/render": "^0.0.10",
|
||||||
"@supabase/ssr": "^0.3.0",
|
"@supabase/ssr": "^0.3.0",
|
||||||
"@supabase/supabase-js": "^2.42.0",
|
"@supabase/supabase-js": "^2.42.0",
|
||||||
|
|||||||
100
src/components/AuthForm.tsx
Normal file
100
src/components/AuthForm.tsx
Normal file
@@ -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<FormValues>({
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => loginWithOAuth.mutate({ provider: "google" })}
|
||||||
|
className="flex w-full items-center justify-center rounded-md bg-dark-1000 px-3 py-2 text-sm font-semibold leading-6 text-dark-50 shadow-sm focus-visible:outline focus-visible:outline-2"
|
||||||
|
>
|
||||||
|
<FaGoogle className="mr-2" /> Continue with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="mb-[1.5rem] h-[1px] w-full bg-dark-600" />
|
||||||
|
<input
|
||||||
|
{...register("email", { required: true })}
|
||||||
|
placeholder="Enter your email address"
|
||||||
|
autoComplete="email"
|
||||||
|
className=" block w-full rounded-md border-0 bg-dark-500 bg-white/5 py-2 text-dark-1000 shadow-sm ring-1 ring-inset ring-dark-600 focus:ring-inset focus:ring-dark-800 sm:text-sm sm:leading-6"
|
||||||
|
/>
|
||||||
|
{!loginWithEmail.error && !loginWithOAuth.error && errors.email && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">
|
||||||
|
Please enter a valid email address
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{loginWithEmail.error ?? loginWithOAuth.error ? (
|
||||||
|
<p className="mt-2 text-xs text-red-400">
|
||||||
|
Something went wrong, please try again later or contact customer
|
||||||
|
support.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-[1.5rem]">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loginWithEmail.isPending}
|
||||||
|
className="flex w-full items-center justify-center rounded-md bg-dark-600 px-3 py-2 text-sm font-semibold leading-6 text-dark-1000 shadow-sm focus-visible:outline focus-visible:outline-2"
|
||||||
|
>
|
||||||
|
{loginWithEmail.isPending ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : (
|
||||||
|
"Continue with email"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/LoadingSpinner.tsx
Normal file
26
src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const LoadingSpinner = () => {
|
||||||
|
return (
|
||||||
|
<svg className="h-6 w-6 animate-spin" viewBox="0 0 100 100">
|
||||||
|
<circle
|
||||||
|
fill="none"
|
||||||
|
stroke-width="10"
|
||||||
|
className="stroke-current opacity-40"
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="40"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
fill="none"
|
||||||
|
stroke-width="10"
|
||||||
|
className="stroke-current"
|
||||||
|
stroke-dasharray="280"
|
||||||
|
stroke-dashoffset="210"
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="40"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
@@ -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 (
|
|
||||||
<Formik
|
|
||||||
initialValues={{
|
|
||||||
email: "",
|
|
||||||
}}
|
|
||||||
onSubmit={(values: FormValues) => {
|
|
||||||
login.mutate({
|
|
||||||
email: values.email,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Form className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="email"
|
|
||||||
className="block text-sm font-normal leading-6 text-dark-1000"
|
|
||||||
>
|
|
||||||
Email address
|
|
||||||
</label>
|
|
||||||
<div className="mt-2">
|
|
||||||
<Field
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
className="block w-full rounded-md border-0 bg-dark-500 bg-white/5 py-1.5 text-dark-1000 shadow-sm ring-1 ring-inset ring-dark-900 focus:ring-2 focus:ring-inset focus:ring-dark-900 sm:text-sm sm:leading-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="focus-visible:outline-offset- flex w-full justify-center rounded-md bg-dark-1000 px-3 py-2 text-sm font-semibold leading-6 text-dark-50 shadow-sm focus-visible:outline focus-visible:outline-2"
|
|
||||||
>
|
|
||||||
Sign up
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,35 +19,43 @@ export default async function handler(
|
|||||||
const queryParams = req.query;
|
const queryParams = req.query;
|
||||||
const token_hash = stringOrFirstString(queryParams.token_hash);
|
const token_hash = stringOrFirstString(queryParams.token_hash);
|
||||||
const type = stringOrFirstString(queryParams.type);
|
const type = stringOrFirstString(queryParams.type);
|
||||||
|
const code = stringOrFirstString(queryParams.code);
|
||||||
|
|
||||||
let next = "/error";
|
let next = "/error";
|
||||||
|
|
||||||
if (token_hash && type) {
|
let authRes;
|
||||||
|
|
||||||
|
if ((token_hash && type) ?? code) {
|
||||||
const db = createNextClient(req, res);
|
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) {
|
if (code) {
|
||||||
const user = await db
|
authRes = await db.auth.exchangeCodeForSession(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = authRes?.data.user;
|
||||||
|
|
||||||
|
if (user?.id) {
|
||||||
|
const existingUser = await db
|
||||||
.from("user")
|
.from("user")
|
||||||
.select()
|
.select()
|
||||||
.eq("id", data.user.id)
|
.eq("id", user.id)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!user.data) {
|
if (!existingUser.data) {
|
||||||
await db
|
await db.from("user").insert({ id: user.id, email: user.email ?? "" });
|
||||||
.from("user")
|
|
||||||
.insert({ id: data.user.id, email: data.user.email ?? "" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (authRes?.error) {
|
||||||
console.error(error);
|
console.error(authRes.error);
|
||||||
} else {
|
} else {
|
||||||
next = stringOrFirstString(queryParams.next) ?? "/";
|
next = stringOrFirstString(queryParams.next) ?? "/";
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/pages/signup/index.tsx
Normal file
5
src/pages/signup/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import SignupView from "~/views/auth/signup";
|
||||||
|
|
||||||
|
export default function SignupPage() {
|
||||||
|
return <SignupView />;
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import VerifyView from "~/views/auth/verify";
|
|
||||||
|
|
||||||
export default function VerifyPage() {
|
|
||||||
return <VerifyView />;
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
login: publicProcedure
|
loginWithEmail: publicProcedure
|
||||||
.input(z.object({ email: z.string() }))
|
.input(z.object({ email: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { data } = await ctx.db.auth.signInWithOtp({
|
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;
|
return data;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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() {
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||||
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<main className="flex h-screen flex-col items-center justify-center bg-dark-50">
|
<main className="h-screen bg-dark-50">
|
||||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-dark-1000">
|
<div className="flex h-full flex-col items-center justify-center">
|
||||||
kan.bn
|
<h1 className="mb-6 text-lg font-bold tracking-tight text-dark-1000">
|
||||||
</h1>
|
kan.bn
|
||||||
<p className="mb-10 text-3xl text-dark-1000">Get started</p>
|
</h1>
|
||||||
<div className="w-full rounded-md border border-dark-600 bg-dark-300 px-10 py-10 sm:max-w-md">
|
<p className="mb-10 text-3xl text-dark-1000">
|
||||||
<div className=" sm:mx-auto sm:w-full sm:max-w-sm">
|
{isMagicLinkSent ? "Check your inbox" : "Welcome back"}
|
||||||
<Login />
|
</p>
|
||||||
</div>
|
{isMagicLinkSent ? (
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
|
<p className="text-md mt-2 text-center text-dark-1000">
|
||||||
|
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full rounded-lg border border-dark-400 bg-dark-200 px-10 py-10 sm:max-w-md">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
|
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-10 text-center text-sm text-dark-1000">
|
|
||||||
{"Already have an account?"}
|
|
||||||
</p>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/views/auth/signup/index.tsx
Normal file
51
src/views/auth/signup/index.tsx
Normal file
@@ -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<boolean>(false);
|
||||||
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<main className="h-screen bg-dark-50">
|
||||||
|
<div className="flex h-full flex-col items-center justify-center">
|
||||||
|
<h1 className="mb-6 text-lg font-bold tracking-tight text-dark-1000">
|
||||||
|
kan.bn
|
||||||
|
</h1>
|
||||||
|
<p className="mb-10 text-3xl text-dark-1000">
|
||||||
|
{isMagicLinkSent ? "Check your inbox" : "Get started"}
|
||||||
|
</p>
|
||||||
|
{isMagicLinkSent ? (
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
|
<p className="text-md mt-2 text-center text-dark-1000">
|
||||||
|
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full rounded-lg border border-dark-400 bg-dark-200 px-10 py-10 sm:max-w-md">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
|
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export default function VerifyPage() {
|
|
||||||
return (
|
|
||||||
<main className="bg-dark-50 flex h-screen flex-col items-center justify-center">
|
|
||||||
<p className="text-dark-1000 mb-10 text-3xl">Check your inbox</p>
|
|
||||||
<div className=" sm:mx-auto sm:w-full sm:max-w-sm">
|
|
||||||
<p className="text-dark-1000 text-md mt-5 text-center">
|
|
||||||
{`Click on the link we've sent to you@example.com to sign in.`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user