diff --git a/apps/backend/package.json b/apps/backend/package.json index 7ba49d4..0e16439 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,6 +1,6 @@ { "name": "@notes/backend", - "version": "0.0.8", + "version": "0.0.9", "scripts": { "dev": "NODE_DEBUG=* NODE_ENV=development bun --watch src/index.ts", "build": "NODE_ENV=production bun build --target=bun --sourcemap=linked ./src/index.ts --outdir ./dist", @@ -20,6 +20,7 @@ "@lucia-auth/adapter-drizzle": "^1.0.7", "@react-email/components": "^0.0.21", "@sentry/bun": "^8.25.0", + "arctic": "^1.9.2", "bullmq": "^5.12.0", "country-code-lookup": "^0.1.3", "drizzle-orm": "^0.31.4", @@ -50,4 +51,4 @@ "prettier": "^3.3.3", "typescript": "^5.5.4" } -} +} \ No newline at end of file diff --git a/apps/backend/src/db/schema/user.ts b/apps/backend/src/db/schema/user.ts index b0591b5..01756b0 100644 --- a/apps/backend/src/db/schema/user.ts +++ b/apps/backend/src/db/schema/user.ts @@ -1,4 +1,4 @@ -import { InferSelectModel, relations } from "drizzle-orm"; +import { InferInsertModel, InferSelectModel, relations } from "drizzle-orm"; import { boolean, index, @@ -62,6 +62,7 @@ export const userTable = pgTable( ); export type UserTable = InferSelectModel; +export type CreateUserTable = InferInsertModel; export const sessionTable = pgTable("session", { id: text("id").notNull().primaryKey(), diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 3cc7ead..3de22cd 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -12,7 +12,10 @@ const server = t.Object({ APP_NAME: t.String({ minLength: 1 }), ADMIN_EMAIL: t.String({ minLength: 1, format: "email" }), REDIS_URL: t.String({ minLength: 1 }), - IPINFO_TOKEN: t.String({ minLength: 1 }) + IPINFO_TOKEN: t.String({ minLength: 1 }), + GOOGLE_CLIENT_ID: t.String({ minLength: 1 }), + GOOGLE_CLIENT_SECRET: t.String({ minLength: 1 }), + GOOGLE_REDIRECT_URI: t.String({ minLength: 1 }) }); type ServerEnv = typeof server.static; @@ -28,7 +31,10 @@ const processEnv = { APP_NAME: process.env.APP_NAME, ADMIN_EMAIL: process.env.ADMIN_EMAIL, REDIS_URL: process.env.REDIS_URL, - IPINFO_TOKEN: process.env.IPINFO_TOKEN + IPINFO_TOKEN: process.env.IPINFO_TOKEN, + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, + GOOGLE_REDIRECT_URI: process.env.GOOGLE_REDIRECT_URI } satisfies Record; // Don't touch the part below diff --git a/apps/backend/src/libs/auth.ts b/apps/backend/src/libs/auth.ts index e955c05..84f1733 100644 --- a/apps/backend/src/libs/auth.ts +++ b/apps/backend/src/libs/auth.ts @@ -1,6 +1,9 @@ import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle"; +import { Google } from "arctic"; import { Lucia } from "lucia"; +import { env } from "@/env"; + import { db } from "../db"; import { sessionTable, userTable } from "../db/schema/user"; @@ -24,6 +27,12 @@ export const lucia = new Lucia(adapter, { } }); +export const google = new Google( + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + env.GOOGLE_REDIRECT_URI +); + // IMPORTANT! declare module "lucia" { interface Register { diff --git a/apps/backend/src/libs/background/save-device.ts b/apps/backend/src/libs/background/save-device.ts index 7a2c1d4..55e4d55 100644 --- a/apps/backend/src/libs/background/save-device.ts +++ b/apps/backend/src/libs/background/save-device.ts @@ -22,7 +22,7 @@ export type SaveDeviceProps = { userId: number; sessionId: string; ua?: string; - device: DeviceSchema; + device?: DeviceSchema; }; export async function saveDevice({ diff --git a/apps/backend/src/libs/parse-json.ts b/apps/backend/src/libs/parse-json.ts new file mode 100644 index 0000000..eb5db44 --- /dev/null +++ b/apps/backend/src/libs/parse-json.ts @@ -0,0 +1,36 @@ +type ParseJsonResponse = + | { data: T; error: null } + | { data: null; error: string }; + +// Overload for asynchronous parsing (Response) +export async function parseJson>( + toParse: Response +): Promise>; + +// Overload for synchronous parsing (string) +export function parseJson>( + toParse: unknown +): ParseJsonResponse; + +// Implementation +export function parseJson>( + toParse: unknown +): ParseJsonResponse | Promise> { + if (toParse instanceof Response) { + return toParse + .json() + .then((data) => ({ data, error: null })) + .catch(() => ({ data: null, error: "Invalid JSON" })); + } + + if (typeof toParse === "string") { + try { + const data = JSON.parse(toParse); + return { data, error: null }; + } catch (error) { + return { data: null, error: "Invalid JSON" }; + } + } + + return { data: null, error: "Invalid input" }; +} diff --git a/apps/backend/src/libs/sentry.ts b/apps/backend/src/libs/sentry.ts index f3dc3a9..c185ddf 100644 --- a/apps/backend/src/libs/sentry.ts +++ b/apps/backend/src/libs/sentry.ts @@ -1,7 +1,9 @@ import * as Sentry from "@sentry/bun"; -Sentry.init({ - dsn: "https://efc7013b1ac25cd69a614607b7c17f65@o4507646828609536.ingest.us.sentry.io/4507772148776960", - // Tracing - tracesSampleRate: 1.0 // Capture 100% of the transactions -}); +if (process.env.NODE_ENV === "production") { + Sentry.init({ + dsn: "https://efc7013b1ac25cd69a614607b7c17f65@o4507646828609536.ingest.us.sentry.io/4507772148776960", + // Tracing + tracesSampleRate: 1.0 // Capture 100% of the transactions + }); +} diff --git a/apps/backend/src/v1/controllers/user/google-callback.ts b/apps/backend/src/v1/controllers/user/google-callback.ts new file mode 100644 index 0000000..7daecba --- /dev/null +++ b/apps/backend/src/v1/controllers/user/google-callback.ts @@ -0,0 +1,247 @@ +import * as Sentry from "@sentry/bun"; +import { Value } from "@sinclair/typebox/value"; +import { OAuth2RequestError } from "arctic"; +import { and, eq } from "drizzle-orm"; +import { Context } from "elysia"; +import { z } from "zod"; + +import { db } from "@/db"; +import { UserTable, oauthAccountTable, userTable } from "@/db/schema/user"; +import { env } from "@/env"; +import { google, lucia } from "@/libs/auth"; +import { BgQueue } from "@/libs/background-worker"; +import { SaveDeviceProps } from "@/libs/background/save-device"; +import { signJwtAsync } from "@/libs/jwt"; +import { parseJson } from "@/libs/parse-json"; +import { Prettify } from "@/types/prettify"; +import { + DeviceSchema, + UserResponse, + deviceSchema +} from "@/v1/validations/user"; + +interface GoogleCallbackProps extends Context { + ip: string; +} + +const googleUserSchema = z.object({ + id: z.string(), + email: z.string().email(), + verified_email: z.boolean().optional().default(false), + name: z.string().optional(), + picture: z.string().optional() +}); + +export async function googleCallback({ + cookie, + request, + error, + ip, + redirect +}: GoogleCallbackProps) { + const codeVerifierCookie = cookie?.google_oauth_code_verifier?.value ?? null; + const stateCookie = cookie?.google_oauth_state?.value ?? null; + const deviceCookie = cookie?.device?.value ?? null; + // request origin uri + const callbackURL = cookie?.callback?.value ?? env.CLIENT_URL + "/login"; + const redirectURL = + cookie?.redirect?.value ?? env.CLIENT_URL + "/login/google/callback"; + + cookie?.google_oauth_state?.remove(); + cookie?.google_oauth_code_verifier?.remove(); + cookie?.device?.remove(); + cookie?.callback?.remove(); + cookie?.redirect?.remove(); + + const jsonDevice = parseJson(deviceCookie); + + const searchParams = new URL(request.url).searchParams; + const state = searchParams.get("state"); + const code = searchParams.get("code"); + + // verify state + if (!state || !codeVerifierCookie || !code || state !== stateCookie) { + return redirect( + `${callbackURL}?error=${encodeURIComponent("Invalid state or codeVerifier")}` + ); + } + + try { + const tokens = await google.validateAuthorizationCode( + code, + codeVerifierCookie + ); + const googleUserResponse = await fetch( + "https://www.googleapis.com/oauth2/v1/userinfo", + { + headers: { + Authorization: `Bearer ${tokens.accessToken}` + } + } + ); + + if (!googleUserResponse.ok) { + console.log(googleUserResponse?.text); + console.log( + "🚀 ~ googleUserResponse:", + await googleUserResponse.json().catch(console.error) + ); + return redirect( + `${callbackURL}?error=${encodeURIComponent("Failed to fetch user info from google")}` + ); + } + + const { data: googleUserResult, error: googleUserDataError } = + await parseJson(googleUserResponse); + + if (googleUserDataError || !googleUserResult) { + return redirect( + `${callbackURL}?error=${encodeURIComponent(googleUserDataError)}` + ); + } + + const googleUser = googleUserSchema.parse(googleUserResult); + + const { user, error: dbError } = await db.transaction(async (trx) => { + const [existingUser] = await trx + .select() + .from(oauthAccountTable) + .where( + and( + eq(oauthAccountTable.providerId, "google"), + eq(oauthAccountTable.providerUserId, googleUser.id) + ) + ) + .innerJoin(userTable, eq(oauthAccountTable.userId, userTable.id)); + + if (!existingUser) { + const [createdUser] = await trx + .insert(userTable) + .values({ + email: googleUser.email, + emailVerified: googleUser.verified_email, + disabled: false, + displayName: googleUser.name, + photoURL: googleUser.picture + }) + .returning(); + + if (!createdUser) { + trx.rollback(); + return { user: null, error: "Failed to create user" }; + } + + const [createdOauthAccount] = await trx + .insert(oauthAccountTable) + .values({ + providerId: "google", + providerUserId: googleUser.id, + userId: createdUser.id + }) + .returning(); + + if (!createdOauthAccount) { + trx.rollback(); + return { user: null, error: "Failed to create oauth account" }; + } + + return { user: createdUser, error: null }; + } else { + const [updatedUser] = await trx + .update(userTable) + .set(getUserPropToUpdate(existingUser.user, googleUser)) + .where(eq(userTable.id, existingUser.user.id)) + .returning(); + + if (!updatedUser) { + trx.rollback(); + return { user: null, error: "Failed to update user" }; + } + + return { user: updatedUser, error: null }; + } + }); + + if (dbError || !user) { + return redirect( + `${callbackURL}?error=${encodeURIComponent(dbError ?? "Failed to create user")}` + ); + } + + const session = await lucia.createSession(user.id, {}); + + const sessionToken = await signJwtAsync(session.id); + + const parsedDevice = Value.Check(deviceSchema, jsonDevice.data); + + await BgQueue.add("saveDevice", { + ip, + ua: request.headers.get("user-agent") ?? undefined, + userId: user.id, + sessionId: session.id, + device: parsedDevice ? (deviceCookie as DeviceSchema) : undefined + } satisfies SaveDeviceProps).catch((err) => { + console.log("🚀 ~ loginUser saveDevice ~ err", err); + Sentry.captureException(err); + }); + + const sessionUser = { + id: user.id, + email: user.email, + emailVerified: user.emailVerified, + photoURL: user.photoURL, + displayName: user.displayName + } satisfies UserResponse; + + return redirect( + `${redirectURL}?sessionToken=${sessionToken}&user=${encodeURIComponent(JSON.stringify(sessionUser))}` + ); + } catch (err) { + console.log("🚀 googleCallback ~ err:", err); + Sentry.captureException(err); + + if (err instanceof OAuth2RequestError) { + // bad verification code, invalid credentials, etc + return redirect( + `${callbackURL}?error=${encodeURIComponent("invalid verification code")}` + ); + } + + if ((err as any)?.code === "23505") { + return redirect( + `${callbackURL}?error=${encodeURIComponent("Email already exists, please use different account")}` + ); + } + + return redirect( + `${callbackURL}?error=${encodeURIComponent("internal server error")}` + ); + } +} + +type UserPropToUpdate = Partial< + Pick +>; + +function getUserPropToUpdate( + user: UserTable, + googleUser: z.infer +) { + const propsToUpdate: Prettify = {}; + + if (!user.emailVerified === false) { + propsToUpdate.emailVerified = googleUser.verified_email; + } + + if (!user.displayName) { + propsToUpdate.displayName = googleUser.name; + } + + if (!user.photoURL) { + propsToUpdate.photoURL = googleUser.picture; + } + + propsToUpdate.lastSignInAt = new Date(); + + return propsToUpdate; +} diff --git a/apps/backend/src/v1/controllers/user/google-login.ts b/apps/backend/src/v1/controllers/user/google-login.ts new file mode 100644 index 0000000..750c36b --- /dev/null +++ b/apps/backend/src/v1/controllers/user/google-login.ts @@ -0,0 +1,73 @@ +import { generateCodeVerifier, generateState } from "arctic"; +import { Context } from "elysia"; + +import { google } from "@/libs/auth"; +import { OAuthQuerySchema } from "@/v1/validations/user"; + +interface GoogleLoginProps extends Omit { + query: OAuthQuerySchema; +} + +export async function googleLogin({ + cookie, + redirect, + query, + request +}: GoogleLoginProps) { + console.log(request.url); + + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + + const url = await google.createAuthorizationURL(state, codeVerifier, { + scopes: ["email", "profile"] + }); + + cookie?.google_oauth_code_verifier?.set({ + value: codeVerifier, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 10, + path: "/" + }); + + cookie?.google_oauth_state?.set({ + value: state, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 10, + path: "/" + }); + + if (query.device) { + cookie?.device?.set({ + value: query.device, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 10, + path: "/" + }); + } + + if (query.redirect) { + cookie.redirect?.set({ + value: query.redirect, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 10, + path: "/" + }); + } + + if (query.callback) { + cookie.callback?.set({ + value: query.callback, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 10, + path: "/" + }); + } + + return redirect(url.toString(), 302); +} diff --git a/apps/backend/src/v1/routes/auth.ts b/apps/backend/src/v1/routes/auth.ts index 0187caa..301b07c 100644 --- a/apps/backend/src/v1/routes/auth.ts +++ b/apps/backend/src/v1/routes/auth.ts @@ -7,12 +7,15 @@ import { emailVerificationSchema, loginUserSchema, logoutSchema, + oAuthQuerySchema, passwordResetSchema, passwordResetTokenSchema, registerUserSchema } from "@/v1/validations/user"; import { emailVerification } from "../controllers/user/email-verification"; +import { googleCallback } from "../controllers/user/google-callback"; +import { googleLogin } from "../controllers/user/google-login"; import { loginUser } from "../controllers/user/login"; import { logout, logoutAll } from "../controllers/user/logout"; import { passwordReset } from "../controllers/user/password-reset"; @@ -28,7 +31,7 @@ export const authRoute = new Elysia({ prefix: "/auth" }) .use(bearer()) .use( rateLimit({ - max: 5, + max: 3, scoping: "scoped", generator: rateLimiterkeyGenerator }) @@ -39,6 +42,8 @@ export const authRoute = new Elysia({ prefix: "/auth" }) .post("/login", loginUser, { body: loginUserSchema }) + .get("/google", googleLogin) + .get("/google/callback", googleCallback, { query: oAuthQuerySchema }) .post("/logout", logout, { body: logoutSchema }) .post("/logout-all", logoutAll) .get("/profile", getProfile); @@ -49,7 +54,7 @@ export const passwordResetRoute = new Elysia({ prefix: "/auth" }) .use( rateLimit({ scoping: "scoped", - max: 3, + max: 5, generator: rateLimiterkeyGenerator, countFailedRequest: true }) diff --git a/apps/backend/src/v1/validations/user.ts b/apps/backend/src/v1/validations/user.ts index 094a305..7dda1bf 100644 --- a/apps/backend/src/v1/validations/user.ts +++ b/apps/backend/src/v1/validations/user.ts @@ -102,3 +102,13 @@ export const changePasswordSchema = t.Object({ }); export type ChangePasswordSchema = typeof changePasswordSchema.static; + +export const oAuthQuerySchema = t.Partial( + t.Object({ + device: deviceSchema, + redirect: t.String({ format: "uri" }), + callback: t.String({ format: "uri" }) + }) +); + +export type OAuthQuerySchema = typeof oAuthQuerySchema.static; diff --git a/apps/web/package.json b/apps/web/package.json index 50c2f36..b9dce00 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@notes/web", - "version": "2.1.3", + "version": "2.1.4", "private": true, "description": "client", "type": "module", diff --git a/apps/web/src/Routes.tsx b/apps/web/src/Routes.tsx index 1f16df5..31c39af 100644 --- a/apps/web/src/Routes.tsx +++ b/apps/web/src/Routes.tsx @@ -12,6 +12,7 @@ const GeneralPage = loadable(() => import("@/pages/General")); const ImportantPage = loadable(() => import("@/pages/Important")); const PasswordResetPage = loadable(() => import("@/pages/PasswordReset")); const ConfirmEmailPage = loadable(() => import("@/pages/ConfirmEmail")); +const AuthRedirectPage = loadable(() => import("@/pages/AuthRedirect")); export const RouteComponents = [ { @@ -66,6 +67,10 @@ export const RouteComponents = [ } /> ) + }, + { + path: "/login/:provider/callback", + element: } /> } ] as const; diff --git a/apps/web/src/lib/get-auth-provider-url.ts b/apps/web/src/lib/get-auth-provider-url.ts new file mode 100644 index 0000000..4fc543c --- /dev/null +++ b/apps/web/src/lib/get-auth-provider-url.ts @@ -0,0 +1,14 @@ +export function getAuthProviderURL(provider: string) { + const url = new URL( + `${import.meta.env.VITE_API_BASE_URL}/v1/auth/${provider}` + ); + url.searchParams.append( + "callback", + window.location.origin + window.location.pathname + ); + url.searchParams.append( + "redirect", + window.location.origin + `/login/${provider}/callback` + ); + return url.toString(); +} diff --git a/apps/web/src/pages/AuthRedirect.tsx b/apps/web/src/pages/AuthRedirect.tsx new file mode 100644 index 0000000..e537c74 --- /dev/null +++ b/apps/web/src/pages/AuthRedirect.tsx @@ -0,0 +1,56 @@ +import { useEffect } from "react"; +import toast from "react-hot-toast"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +import Loading from "@/components/Loading"; +import { SESSION_TOKEN_KEY } from "@/constant/auth"; +import { useAuthStore } from "@/store/auth"; + +export default function AuthRedirect() { + const user = useAuthStore((state) => state.user); + const setUser = useAuthStore((state) => state.setUser); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + useEffect(() => { + if (user) { + navigate("/", { replace: true }); + } + }, [user]); + + useEffect(() => { + if (searchParams.has("error")) { + const error = searchParams.get("error"); + if (error) toast.error(error); + return; + } + + const sessionToken = searchParams.get("sessionToken"); + const user = searchParams.get("user"); + + if (sessionToken && user) { + try { + const userJson = JSON.parse(user); + + if ( + !userJson?.id || + !userJson?.email || + typeof userJson?.emailVerified !== "boolean" || + typeof userJson?.photoURL !== "string" || + (typeof userJson?.displayName !== "undefined" && + typeof userJson?.displayName !== "string") || + (typeof userJson?.photoURL !== "undefined" && + typeof userJson?.photoURL !== "string") + ) { + throw new Error("Invalid User"); + } + localStorage.setItem(SESSION_TOKEN_KEY, sessionToken); + setUser(JSON.parse(user)); + } catch (error: unknown) { + toast.error("Invalid User"); + } + } + }, [searchParams]); + + return ; +} diff --git a/apps/web/src/pages/SignIn.tsx b/apps/web/src/pages/SignIn.tsx index bc9111d..7c7812f 100644 --- a/apps/web/src/pages/SignIn.tsx +++ b/apps/web/src/pages/SignIn.tsx @@ -1,6 +1,8 @@ +import Google from "@mui/icons-material/Google"; import LockIcon from "@mui/icons-material/LockOutlined"; import Visibility from "@mui/icons-material/Visibility"; import VisibilityOff from "@mui/icons-material/VisibilityOff"; +import LoadingButton from "@mui/lab/LoadingButton"; import { Avatar, Backdrop, @@ -18,11 +20,13 @@ import InputAdornment from "@mui/material/InputAdornment"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { useNavigate } from "react-router"; +import { useSearchParams } from "react-router-dom"; import FormDialog from "@/components/FormDialog"; import { SESSION_TOKEN_KEY } from "@/constant/auth"; import { APIError } from "@/lib/api-error"; import { fetchAPI } from "@/lib/fetch-wrapper"; +import { getAuthProviderURL } from "@/lib/get-auth-provider-url"; import { useAuthStore } from "@/store/auth"; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -43,6 +47,8 @@ export default function SignIn() { const [isResetFormOpen, setIsResetFormOpen] = useState(false); const user = useAuthStore((state) => state.user); const setUser = useAuthStore((state) => state.setUser); + const [isGoogleLoading, setIsGoogleLoading] = useState(false); + const [searchParams] = useSearchParams(); // check for user useEffect(() => { @@ -51,6 +57,13 @@ export default function SignIn() { } }, [user]); + useEffect(() => { + if (searchParams.has("error")) { + const error = searchParams.get("error"); + if (error) toast.error(error); + } + }, [searchParams]); + const handleChange = (prop: keyof State) => (event: React.ChangeEvent) => { setValues({ ...values, [prop]: event.target.value }); @@ -106,6 +119,11 @@ export default function SignIn() { } }; + const handleGoogleLogin = () => { + setIsGoogleLoading(true); + window.location.href = getAuthProviderURL("google"); + }; + return ( <> @@ -175,15 +193,34 @@ export default function SignIn() { ) }} /> - +
+ +
+
+ OR +
+
+ } + fullWidth + variant="contained" + type="button" + disabled={isGoogleLoading} + onClick={handleGoogleLogin} + loading={isGoogleLoading} + > + Google + +
({ user: state.user, setUser: state.setUser })) ); const [showPasswordRules, setShowPasswordRules] = useState(false); + const [isGoogleLoading, setIsGoogleLoading] = useState(false); + const [searchParams] = useSearchParams(); // check for user useEffect(() => { if (user) { navigate("/", { replace: true }); } - }, []); + }, [user]); + + useEffect(() => { + if (searchParams.has("error")) { + const error = searchParams.get("error"); + if (error) toast.error(error); + } + }, [searchParams]); const handleChange = (prop: keyof InputFields) => @@ -151,9 +163,7 @@ export default function SignUp() { email, password, fullName - }, - // auto abort in 2 minutes - options: { signal: AbortSignal.timeout(1000 * 60 * 2) } + } }); if ( @@ -188,6 +198,11 @@ export default function SignUp() { } }; + const handleGoogleLogin = () => { + setIsGoogleLoading(true); + window.location.href = getAuthProviderURL("google"); + }; + return ( <> @@ -230,7 +245,7 @@ export default function SignUp() { e.preventDefault(); handleSubmit(); }} - sx={{ mt: 1 }} + sx={{ mt: 1, mb: 3 }} id="signup-form" > - + +
+ +
+
+ OR +
+
+ } + fullWidth + variant="contained" + type="button" + disabled={isGoogleLoading} + onClick={handleGoogleLogin} + loading={isGoogleLoading} + > + Google + +
diff --git a/bun.lockb b/bun.lockb index 07edcbc..b170e4e 100644 Binary files a/bun.lockb and b/bun.lockb differ