From 1e4fe071aa95a5d8346b770a406d95744589c684 Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Fri, 26 Jan 2024 12:27:43 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=91=20Add=20option=20to=20log=20in=20w?= =?UTF-8?q?ith=20google=20=20account=20(#997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 3 +- apps/web/public/static/google.svg | 1 + .../app/[locale]/(auth)/login/login-form.tsx | 98 +++++++++++++------ .../src/app/[locale]/(auth)/login/page.tsx | 9 +- apps/web/src/app/posthog.ts | 12 +++ apps/web/src/utils/auth.ts | 49 +++++++++- apps/web/src/utils/constants.ts | 8 -- apps/web/tests/authentication.spec.ts | 20 ++-- turbo.json | 2 + yarn.lock | 85 ++++++++++++++-- 10 files changed, 217 insertions(+), 70 deletions(-) create mode 100644 apps/web/public/static/google.svg create mode 100644 apps/web/src/app/posthog.ts diff --git a/apps/web/package.json b/apps/web/package.json index 16179216e..9037c7282 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -61,7 +61,8 @@ "next-seo": "^5.15.0", "php-serialize": "^4.1.1", "postcss": "^8.4.31", - "posthog-js": "^1.57.2", + "posthog-js": "^1.102.1", + "posthog-node": "^3.6.0", "react-big-calendar": "^1.8.1", "react-hook-form": "^7.42.1", "react-hook-form-persist": "^3.0.0", diff --git a/apps/web/public/static/google.svg b/apps/web/public/static/google.svg new file mode 100644 index 000000000..088288fa3 --- /dev/null +++ b/apps/web/public/static/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/app/[locale]/(auth)/login/login-form.tsx b/apps/web/src/app/[locale]/(auth)/login/login-form.tsx index 3ad53174f..d3d2dffa4 100644 --- a/apps/web/src/app/[locale]/(auth)/login/login-form.tsx +++ b/apps/web/src/app/[locale]/(auth)/login/login-form.tsx @@ -1,22 +1,25 @@ "use client"; import { Button } from "@rallly/ui/button"; -import { LogInIcon, UserIcon } from "lucide-react"; -import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { UserIcon } from "lucide-react"; +import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; -import { signIn, useSession } from "next-auth/react"; +import { getProviders, signIn, useSession } from "next-auth/react"; import { usePostHog } from "posthog-js/react"; import React from "react"; import { useForm } from "react-hook-form"; -import { Trans, useTranslation } from "react-i18next"; +import { useTranslation } from "react-i18next"; import { trpc } from "@/app/providers"; import { VerifyCode, verifyCode } from "@/components/auth/auth-forms"; +import { Spinner } from "@/components/spinner"; import { TextInput } from "@/components/text-input"; -import { IfCloudHosted } from "@/contexts/environment"; import { isSelfHosted } from "@/utils/constants"; import { validEmail } from "@/utils/form-validation"; -export function LoginForm({ oidcConfig }: { oidcConfig?: { name: string } }) { +const allowGuestAccess = !isSelfHosted; + +export function LoginForm() { const { t } = useTranslation(); const { register, handleSubmit, getValues, formState, setError } = useForm<{ @@ -25,6 +28,11 @@ export function LoginForm({ oidcConfig }: { oidcConfig?: { name: string } }) { defaultValues: { email: "" }, }); + const { data: providers } = useQuery(["providers"], getProviders, { + cacheTime: Infinity, + staleTime: Infinity, + }); + const session = useSession(); const queryClient = trpc.useUtils(); const [email, setEmail] = React.useState(); @@ -32,9 +40,55 @@ export function LoginForm({ oidcConfig }: { oidcConfig?: { name: string } }) { const router = useRouter(); const callbackUrl = (useSearchParams()?.get("callbackUrl") as string) ?? "/"; - const hasOIDCProvider = !!oidcConfig; - const allowGuestAccess = !isSelfHosted; - const hasAlternativeLoginMethods = hasOIDCProvider || allowGuestAccess; + const alternativeLoginMethods = React.useMemo(() => { + const res: Array<{ login: () => void; icon: JSX.Element; name: string }> = + []; + if (allowGuestAccess) { + res.push({ + login: () => { + router.push(callbackUrl); + }, + icon: , + name: t("continueAsGuest"), + }); + } + + if (providers?.oidc) { + res.push({ + login: () => { + signIn("oidc", { + callbackUrl, + }); + }, + icon: , + name: t("loginWith", { provider: providers.oidc.name }), + }); + } + + if (providers?.google) { + res.push({ + login: () => { + signIn("google", { + callbackUrl, + }); + }, + icon: ( + Google + ), + name: t("loginWith", { provider: providers.google.name }), + }); + } + return res; + }, [callbackUrl, providers, router, t]); + + if (!providers) { + return ( +
+ +
+ ); + } + const sendVerificationEmail = (email: string) => { return signIn("email", { redirect: false, @@ -127,30 +181,16 @@ export function LoginForm({ oidcConfig }: { oidcConfig?: { name: string } }) { > {t("continue")} - {hasAlternativeLoginMethods ? ( + {alternativeLoginMethods.length > 0 ? ( <>
- - - - {hasOIDCProvider ? ( - - ) : null} + ))}
) : null} diff --git a/apps/web/src/app/[locale]/(auth)/login/page.tsx b/apps/web/src/app/[locale]/(auth)/login/page.tsx index 128abbbbd..10caefed9 100644 --- a/apps/web/src/app/[locale]/(auth)/login/page.tsx +++ b/apps/web/src/app/[locale]/(auth)/login/page.tsx @@ -5,20 +5,13 @@ import { LoginForm } from "@/app/[locale]/(auth)/login/login-form"; import { Params } from "@/app/[locale]/types"; import { getTranslation } from "@/app/i18n"; import { AuthCard } from "@/components/auth/auth-layout"; -import { isOIDCEnabled, oidcName } from "@/utils/constants"; - -// Self-hosted instances only have env vars for OIDC at runtime, so we need to -// use force-dynamic to avoid statically rendering this page during build time. -export const dynamic = "force-dynamic"; export default async function LoginPage({ params }: { params: Params }) { const { t } = await getTranslation(params.locale); return (
- +
({ adapter: CustomPrismaAdapter(prisma), @@ -145,7 +160,24 @@ const getAuthOptions = (...args: GetServerSessionParams) => error: "/auth/error", }, callbacks: { - async signIn({ user, email }) { + async signIn({ user, email, account, profile }) { + const posthog = PostHogClient(); + // prevent sign in if email is not verified + if ( + profile && + "email_verified" in profile && + profile.email_verified === false + ) { + posthog?.capture({ + distinctId: user.id, + event: "login failed", + properties: { + reason: "email not verified", + }, + }); + await posthog?.shutdownAsync(); + return false; + } // Make sure email is allowed if (user.email) { const isBlocked = isEmailBlocked(user.email); @@ -175,6 +207,15 @@ const getAuthOptions = (...args: GetServerSessionParams) => if (session && session.user.email === null) { await mergeGuestsIntoUser(user.id, [session.user.id]); } + + posthog?.capture({ + distinctId: user.id, + event: "login", + properties: { + method: account?.provider, + }, + }); + await posthog?.shutdownAsync(); } return true; diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index fe72560ca..ad748850a 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -12,11 +12,3 @@ export const monthlyPriceUsd = 7; export const annualPriceUsd = 42; export const appVersion = process.env.NEXT_PUBLIC_APP_VERSION; - -export const isOIDCEnabled = Boolean( - process.env.OIDC_DISCOVERY_URL && - process.env.OIDC_CLIENT_ID && - process.env.OIDC_CLIENT_SECRET, -); - -export const oidcName = process.env.OIDC_NAME ?? "OpenID Connect"; diff --git a/apps/web/tests/authentication.spec.ts b/apps/web/tests/authentication.spec.ts index cb34bb48c..7d4c05c3f 100644 --- a/apps/web/tests/authentication.spec.ts +++ b/apps/web/tests/authentication.spec.ts @@ -46,7 +46,7 @@ test.describe.serial(() => { .getByPlaceholder("jessie.smith@example.com") .type(testUserEmail); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); // Make sure the user doesn't exist yet and that logging in is not possible await expect( @@ -64,7 +64,7 @@ test.describe.serial(() => { .getByPlaceholder("jessie.smith@example.com") .type(testUserEmail); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); const codeInput = page.getByPlaceholder("Enter your 6-digit code"); @@ -72,7 +72,7 @@ test.describe.serial(() => { await codeInput.type(code); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.waitForURL("/polls"); }); @@ -89,7 +89,7 @@ test.describe.serial(() => { .getByPlaceholder("jessie.smith@example.com") .type(testUserEmail); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); await expect( page.getByText("A user with that email already exists"), @@ -103,7 +103,7 @@ test.describe.serial(() => { .getByPlaceholder("jessie.smith@example.com") .type(testUserEmail); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); const { email } = await mailServer.captureOne(testUserEmail, { wait: 5000, @@ -119,7 +119,7 @@ test.describe.serial(() => { await page.goto(magicLink); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.waitForURL("/polls"); @@ -133,13 +133,13 @@ test.describe.serial(() => { .getByPlaceholder("jessie.smith@example.com") .type(testUserEmail); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); const code = await getCode(); await page.getByPlaceholder("Enter your 6-digit code").type(code); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.waitForURL("/polls"); @@ -153,13 +153,13 @@ test.describe.serial(() => { .getByPlaceholder("jessie.smith@example.com") .type("Test@example.com"); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); const code = await getCode(); await page.getByPlaceholder("Enter your 6-digit code").type(code); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.waitForURL("/polls"); diff --git a/turbo.json b/turbo.json index 519d7ae20..590142131 100644 --- a/turbo.json +++ b/turbo.json @@ -89,6 +89,8 @@ "OIDC_DISCOVERY_URL", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", "PADDLE_PUBLIC_KEY", "PORT", "SECRET_PASSWORD", diff --git a/yarn.lock b/yarn.lock index 6263c31fb..acd457488 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4725,6 +4725,11 @@ asynciterator.prototype@^1.0.0: dependencies: has-symbols "^1.0.3" +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + autoprefixer@^10.4.13: version "10.4.13" resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.13.tgz" @@ -4754,6 +4759,15 @@ axios@^0.25.0: dependencies: follow-redirects "^1.14.7" +axios@^1.6.2: + version "1.6.7" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7" + integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA== + dependencies: + follow-redirects "^1.15.4" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^3.1.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -5178,6 +5192,13 @@ color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + comma-separated-tokens@^2.0.0: version "2.0.3" resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz" @@ -5500,6 +5521,11 @@ define-properties@^1.2.0, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + depd@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz" @@ -6504,6 +6530,11 @@ follow-redirects@^1.14.7: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== +follow-redirects@^1.15.4: + version "1.15.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" + integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz" @@ -6528,6 +6559,15 @@ for-own@^0.1.1, for-own@^0.1.2, for-own@^0.1.3, for-own@^0.1.4: dependencies: for-in "^1.0.1" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + fraction.js@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz" @@ -8499,6 +8539,18 @@ micromatch@^4.0.4, micromatch@^4.0.5: braces "^3.0.2" picomatch "^2.3.1" +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" @@ -9295,13 +9347,21 @@ postcss@8.4.31, postcss@^8.4.23, postcss@^8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -posthog-js@^1.57.2: - version "1.57.2" - resolved "https://registry.npmjs.org/posthog-js/-/posthog-js-1.57.2.tgz" - integrity sha512-ER4gkYZasrd2Zwmt/yLeZ5G/nZJ6tpaYBCpx3CvocDx+3F16WdawJlYMT0IyLKHXDniC5+AsjzFd6fi8uyYlJA== +posthog-js@^1.102.1: + version "1.102.1" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.102.1.tgz#ac9e97703f4bba61785b1d1fa7f699516e97c6fb" + integrity sha512-vHkLtnjDce8qxoKX9K4HOWEvCW/xPTEzHDBJIPrhjWCfXLPa5NePEeSiQjr64BV4DFMBbvyNtUUPVstZoIqQcw== dependencies: fflate "^0.4.1" - rrweb-snapshot "^1.1.14" + preact "^10.19.3" + +posthog-node@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/posthog-node/-/posthog-node-3.6.0.tgz#dab0bd2d1c974b0f11052115e8cd0f3e61f7db30" + integrity sha512-N/4//SIQR4fhwbHnDdJ2rQCYdu9wo0EVPK4lVgZswp5R/E42RKlpuO6ZfPsBl+Bcg06OYiOd/WR/jLV90FCoSw== + dependencies: + axios "^1.6.2" + rusha "^0.8.14" preact-render-to-string@5.2.3: version "5.2.3" @@ -9322,6 +9382,11 @@ preact@10.11.3: resolved "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz" integrity sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg== +preact@^10.19.3: + version "10.19.3" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.19.3.tgz#7a7107ed2598a60676c943709ea3efb8aaafa899" + integrity sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ== + preact@^10.6.3: version "10.18.1" resolved "https://registry.npmjs.org/preact/-/preact-10.18.1.tgz" @@ -10134,11 +10199,6 @@ rollup@2.78.0: optionalDependencies: fsevents "~2.3.2" -rrweb-snapshot@^1.1.14: - version "1.1.14" - resolved "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-1.1.14.tgz" - integrity sha512-eP5pirNjP5+GewQfcOQY4uBiDnpqxNRc65yKPW0eSoU1XamDfc4M8oqpXGMyUyvLyxFDB0q0+DChuxxiU2FXBQ== - rtl-css-js@^1.14.0: version "1.16.1" resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz" @@ -10160,6 +10220,11 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +rusha@^0.8.14: + version "0.8.14" + resolved "https://registry.yarnpkg.com/rusha/-/rusha-0.8.14.tgz#a977d0de9428406138b7bb90d3de5dcd024e2f68" + integrity sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA== + rxjs@^7.5.4: version "7.8.0" resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz"