🔑 Add option to log in with google account (#997)

This commit is contained in:
Luke Vella 2024-01-26 12:27:43 +07:00 committed by GitHub
parent c185e73825
commit 1e4fe071aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 217 additions and 70 deletions

View file

@ -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",

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/><path d="M1 1h22v22H1z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 742 B

View file

@ -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<string>();
@ -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: <UserIcon className="text-muted-foreground size-5" />,
name: t("continueAsGuest"),
});
}
if (providers?.oidc) {
res.push({
login: () => {
signIn("oidc", {
callbackUrl,
});
},
icon: <UserIcon className="text-muted-foreground size-5" />,
name: t("loginWith", { provider: providers.oidc.name }),
});
}
if (providers?.google) {
res.push({
login: () => {
signIn("google", {
callbackUrl,
});
},
icon: (
<Image src="/static/google.svg" width={20} height={20} alt="Google" />
),
name: t("loginWith", { provider: providers.google.name }),
});
}
return res;
}, [callbackUrl, providers, router, t]);
if (!providers) {
return (
<div className="flex h-72 items-center justify-center">
<Spinner className="text-muted-foreground" />
</div>
);
}
const sendVerificationEmail = (email: string) => {
return signIn("email", {
redirect: false,
@ -127,30 +181,16 @@ export function LoginForm({ oidcConfig }: { oidcConfig?: { name: string } }) {
>
{t("continue")}
</Button>
{hasAlternativeLoginMethods ? (
{alternativeLoginMethods.length > 0 ? (
<>
<hr className="border-grey-500 my-4 border-t" />
<div className="grid gap-4">
<IfCloudHosted>
<Button size="lg" asChild>
<Link href={callbackUrl}>
<UserIcon className="size-4" />
<Trans i18nKey="continueAsGuest" />
</Link>
{alternativeLoginMethods.map((method, i) => (
<Button size="lg" key={i} onClick={method.login}>
{method.icon}
{method.name}
</Button>
</IfCloudHosted>
{hasOIDCProvider ? (
<Button
icon={LogInIcon}
size="lg"
onClick={() => signIn("oidc")}
>
<Trans
i18nKey="loginWith"
values={{ provider: oidcConfig.name }}
/>
</Button>
) : null}
))}
</div>
</>
) : null}

View file

@ -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 (
<div>
<AuthCard>
<LoginForm
oidcConfig={isOIDCEnabled ? { name: oidcName } : undefined}
/>
<LoginForm />
</AuthCard>
<div className="mt-4 pt-4 text-center text-gray-500 sm:text-base">
<Trans

View file

@ -0,0 +1,12 @@
import { PostHog } from "posthog-node";
export function PostHogClient() {
if (!process.env.NEXT_PUBLIC_POSTHOG_API_KEY) return null;
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_API_KEY, {
host: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
flushAt: 1,
flushInterval: 0,
});
return posthogClient;
}

View file

@ -15,12 +15,13 @@ import NextAuth, {
} from "next-auth/next";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import GoogleProvider from "next-auth/providers/google";
import { Provider } from "next-auth/providers/index";
import { PostHogClient } from "@/app/posthog";
import { absoluteUrl } from "@/utils/absolute-url";
import { CustomPrismaAdapter } from "@/utils/auth/custom-prisma-adapter";
import { mergeGuestsIntoUser } from "@/utils/auth/merge-user";
import { isOIDCEnabled, oidcName } from "@/utils/constants";
import { emailClient } from "@/utils/emails";
const providers: Provider[] = [
@ -109,10 +110,14 @@ const providers: Provider[] = [
];
// If we have an OAuth provider configured, we add it to the list of providers
if (isOIDCEnabled) {
if (
process.env.OIDC_DISCOVERY_URL &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET
) {
providers.push({
id: "oidc",
name: oidcName,
name: process.env.OIDC_NAME ?? "OpenID Connect",
type: "oauth",
wellKnown: process.env.OIDC_DISCOVERY_URL,
authorization: { params: { scope: "openid email profile" } },
@ -131,6 +136,16 @@ if (isOIDCEnabled) {
});
}
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
providers.push(
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
}),
);
}
const getAuthOptions = (...args: GetServerSessionParams) =>
({
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;

View file

@ -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";

View file

@ -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");