mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 13:48:58 -05:00
chore: Auth module revamp (#4335)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com> Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
committed by
GitHub
parent
7598a16b75
commit
f80d1b32b7
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FormControl, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { OTPInput } from "@/modules/ui/components/otp-input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
interface TwoFactorProps {
|
||||
form: UseFormReturn<
|
||||
{
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode?: string | undefined;
|
||||
backupCode?: string | undefined;
|
||||
},
|
||||
any,
|
||||
undefined
|
||||
>;
|
||||
}
|
||||
|
||||
export const TwoFactor = ({ form }: TwoFactorProps) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="totp" className="sr-only">
|
||||
{t("auth.login.enter_your_two_factor_authentication_code")}
|
||||
</label>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="totpCode"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<OTPInput value={field.value ?? ""} onChange={field.onChange} valueLength={6} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { FormControl } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
interface TwoFactorBackupProps {
|
||||
form: UseFormReturn<
|
||||
{
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode?: string | undefined;
|
||||
backupCode?: string | undefined;
|
||||
},
|
||||
any,
|
||||
undefined
|
||||
>;
|
||||
}
|
||||
|
||||
export const TwoFactorBackup = ({ form }: TwoFactorBackupProps) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="totpBackup" className="sr-only">
|
||||
{t("auth.login.backup_code")}
|
||||
</label>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupCode"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
id="totpBackup"
|
||||
required
|
||||
placeholder="XXXXX-XXXXX"
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,379 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TwoFactor } from "@/modules/auth/components/SigninForm/components/TwoFactor";
|
||||
import { TwoFactorBackup } from "@/modules/auth/components/SigninForm/components/TwoFactorBackup";
|
||||
import { createEmailTokenAction } from "@/modules/auth/components/SignupOptions/actions";
|
||||
import { AzureButton } from "@/modules/auth/components/SignupOptions/components/AzureButton";
|
||||
import { GithubButton } from "@/modules/auth/components/SignupOptions/components/GithubButton";
|
||||
import { GoogleButton } from "@/modules/auth/components/SignupOptions/components/GoogleButton";
|
||||
import { OpenIdButton } from "@/modules/auth/components/SignupOptions/components/OpenIdButton";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { XCircleIcon } from "lucide-react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/dist/client/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface TSigninFormState {
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode: string;
|
||||
backupCode: string;
|
||||
}
|
||||
|
||||
interface SignInFormProps {
|
||||
emailAuthEnabled: boolean;
|
||||
publicSignUpEnabled: boolean;
|
||||
passwordResetEnabled: boolean;
|
||||
googleOAuthEnabled: boolean;
|
||||
githubOAuthEnabled: boolean;
|
||||
azureOAuthEnabled: boolean;
|
||||
oidcOAuthEnabled: boolean;
|
||||
oidcDisplayName?: string;
|
||||
isMultiOrgEnabled: boolean;
|
||||
}
|
||||
|
||||
export const SigninForm = ({
|
||||
emailAuthEnabled,
|
||||
publicSignUpEnabled,
|
||||
passwordResetEnabled,
|
||||
googleOAuthEnabled,
|
||||
githubOAuthEnabled,
|
||||
azureOAuthEnabled,
|
||||
oidcOAuthEnabled,
|
||||
oidcDisplayName,
|
||||
isMultiOrgEnabled,
|
||||
}: SignInFormProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const formMethods = useForm<TSigninFormState>();
|
||||
const callbackUrl = searchParams?.get("callbackUrl");
|
||||
const ZSignInInput = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
totpCode: z.string().optional(),
|
||||
backupCode: z.string().optional(),
|
||||
});
|
||||
|
||||
type TSignInInput = z.infer<typeof ZSignInInput>;
|
||||
const form = useForm<TSignInInput>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
totpCode: "",
|
||||
backupCode: "",
|
||||
},
|
||||
resolver: zodResolver(ZSignInInput),
|
||||
});
|
||||
const t = useTranslations();
|
||||
const onSubmit: SubmitHandler<TSigninFormState> = async (data) => {
|
||||
setLoggingIn(true);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Email");
|
||||
}
|
||||
try {
|
||||
const signInResponse = await signIn("credentials", {
|
||||
callbackUrl: callbackUrl ?? "/",
|
||||
email: data.email.toLowerCase(),
|
||||
password: data.password,
|
||||
...(totpLogin && { totpCode: data.totpCode }),
|
||||
...(totpBackup && { backupCode: data.backupCode }),
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (signInResponse?.error === "second factor required") {
|
||||
setTotpLogin(true);
|
||||
setLoggingIn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signInResponse?.error === "Email Verification is Pending") {
|
||||
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
||||
if (emailTokenActionResponse?.serverError) {
|
||||
setSignInError(emailTokenActionResponse.serverError);
|
||||
return;
|
||||
}
|
||||
router.push(`/auth/verification-requested?token=${emailTokenActionResponse?.data}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signInResponse?.error) {
|
||||
setLoggingIn(false);
|
||||
setSignInError(signInResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signInResponse?.error) {
|
||||
router.push(searchParams?.get("callbackUrl") || "/");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error.toString();
|
||||
const errorFeedback = errorMessage.includes("Invalid URL")
|
||||
? t("auth.login.too_many_requests_please_try_again_after_some_time")
|
||||
: error.message;
|
||||
setSignInError(errorFeedback);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [isPasswordFocused, setIsPasswordFocused] = useState(false);
|
||||
const [totpLogin, setTotpLogin] = useState(false);
|
||||
const [totpBackup, setTotpBackup] = useState(false);
|
||||
const [signInError, setSignInError] = useState("");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const error = searchParams?.get("error");
|
||||
const inviteToken = callbackUrl ? new URL(callbackUrl).searchParams.get("token") : null;
|
||||
const [lastLoggedInWith, setLastLoginWith] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setLastLoginWith(localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS) || "");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setSignInError(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const formLabel = useMemo(() => {
|
||||
if (totpBackup) {
|
||||
return t("auth.login.enter_your_backup_code");
|
||||
}
|
||||
|
||||
if (totpLogin) {
|
||||
return t("auth.login.enter_your_two_factor_authentication_code");
|
||||
}
|
||||
|
||||
return t("auth.login.login_to_your_account");
|
||||
}, [totpBackup, totpLogin]);
|
||||
|
||||
const TwoFactorComponent = useMemo(() => {
|
||||
if (totpBackup) {
|
||||
return <TwoFactorBackup form={form} />;
|
||||
}
|
||||
|
||||
if (totpLogin) {
|
||||
return <TwoFactor form={form} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [totpBackup, totpLogin]);
|
||||
|
||||
return (
|
||||
<FormProvider {...formMethods}>
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-slate-700">{formLabel}</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
{TwoFactorComponent}
|
||||
{showLogin && (
|
||||
<div className={cn(totpLogin && "hidden", "space-y-2")}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={field.value}
|
||||
onChange={(email) => field.onChange(email)}
|
||||
placeholder="work@email.com"
|
||||
defaultValue={searchParams?.get("email") || ""}
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
onFocus={() => setIsPasswordFocused(true)}
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{passwordResetEnabled && isPasswordFocused && (
|
||||
<div className="ml-1 text-right transition-all duration-500 ease-in-out">
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="hover:text-brand-dark text-xs text-slate-500">
|
||||
{t("auth.login.forgot_your_password")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{emailAuthEnabled && (
|
||||
<Button
|
||||
size="base"
|
||||
onClick={() => {
|
||||
if (!showLogin) {
|
||||
setShowLogin(true);
|
||||
// Add a slight delay before focusing the input field to ensure it's visible
|
||||
setTimeout(() => emailRef.current?.focus(), 100);
|
||||
} else if (formRef.current) {
|
||||
formRef.current.requestSubmit();
|
||||
}
|
||||
}}
|
||||
className="relative w-full justify-center"
|
||||
loading={loggingIn}>
|
||||
{totpLogin ? t("common.submit") : t("auth.login.login_with_email")}
|
||||
{lastLoggedInWith && lastLoggedInWith === "Email" ? (
|
||||
<span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>
|
||||
) : null}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{googleOAuthEnabled && !totpLogin && (
|
||||
<>
|
||||
<GoogleButton
|
||||
inviteUrl={callbackUrl}
|
||||
lastUsed={lastLoggedInWith === "Google"}
|
||||
text={t("auth.continue_with_google")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{githubOAuthEnabled && !totpLogin && (
|
||||
<>
|
||||
<GithubButton
|
||||
inviteUrl={callbackUrl}
|
||||
lastUsed={lastLoggedInWith === "Github"}
|
||||
text={t("auth.continue_with_github")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{azureOAuthEnabled && !totpLogin && (
|
||||
<>
|
||||
<AzureButton
|
||||
inviteUrl={callbackUrl}
|
||||
lastUsed={lastLoggedInWith === "Azure"}
|
||||
text={t("auth.continue_with_azure")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{oidcOAuthEnabled && !totpLogin && (
|
||||
<>
|
||||
<OpenIdButton
|
||||
inviteUrl={callbackUrl}
|
||||
text={t("auth.continue_with_oidc", { oidcDisplayName })}
|
||||
lastUsed={lastLoggedInWith === "OpenID"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{publicSignUpEnabled && !totpLogin && isMultiOrgEnabled && (
|
||||
<div className="mt-9 text-center text-xs">
|
||||
<span className="leading-5 text-slate-500">{t("auth.login.new_to_formbricks")}</span>
|
||||
<br />
|
||||
<Link
|
||||
href={inviteToken ? `/auth/signup?inviteToken=${inviteToken}` : "/auth/signup"}
|
||||
className="font-semibold text-slate-600 underline hover:text-slate-700">
|
||||
{t("auth.login.create_an_account")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totpLogin && !totpBackup && (
|
||||
<div className="mt-9 text-center text-xs">
|
||||
<span className="leading-5 text-slate-500">{t("auth.login.lost_access")}</span>
|
||||
<br />
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold text-slate-600 underline hover:text-slate-700"
|
||||
onClick={() => {
|
||||
setTotpBackup(true);
|
||||
}}>
|
||||
{t("auth.login.use_a_backup_code")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 font-semibold text-slate-600 underline hover:text-slate-700"
|
||||
onClick={() => {
|
||||
setTotpLogin(false);
|
||||
}}>
|
||||
{t("common.go_back")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpBackup && (
|
||||
<div className="mt-9 text-center text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold text-slate-600 underline hover:text-slate-700"
|
||||
onClick={() => {
|
||||
setTotpBackup(false);
|
||||
}}>
|
||||
{t("common.go_back")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signInError && (
|
||||
<div className="absolute top-10 rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
{t("auth.login.an_error_occurred_when_logging_you_in")}
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p className="space-y-1 whitespace-pre-wrap">{signInError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { z } from "zod";
|
||||
import { createEmailToken } from "@formbricks/lib/jwt";
|
||||
import { getUserByEmail } from "@formbricks/lib/user/service";
|
||||
|
||||
const ZCreateEmailTokenAction = z.object({
|
||||
email: z.string().min(5).max(255).email({ message: "Invalid email" }),
|
||||
});
|
||||
|
||||
export const createEmailTokenAction = actionClient
|
||||
.schema(ZCreateEmailTokenAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const user = await getUserByEmail(parsedInput.email);
|
||||
if (!user) {
|
||||
throw new Error("Invalid request");
|
||||
}
|
||||
|
||||
return createEmailToken(parsedInput.email);
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { MicrosoftIcon } from "@/modules/ui/components/icons";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
export const AzureButton = ({
|
||||
text = "Continue with Azure",
|
||||
inviteUrl,
|
||||
directRedirect = false,
|
||||
lastUsed,
|
||||
}: {
|
||||
text?: string;
|
||||
inviteUrl?: string | null;
|
||||
directRedirect?: boolean;
|
||||
lastUsed?: boolean;
|
||||
}) => {
|
||||
const t = useTranslations();
|
||||
const handleLogin = useCallback(async () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Azure");
|
||||
}
|
||||
|
||||
await signIn("azure-ad", {
|
||||
redirect: true,
|
||||
callbackUrl: inviteUrl ? inviteUrl : "/",
|
||||
});
|
||||
}, [inviteUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (directRedirect) {
|
||||
handleLogin();
|
||||
}
|
||||
}, [directRedirect, handleLogin]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="base"
|
||||
type="button"
|
||||
EndIcon={MicrosoftIcon}
|
||||
startIconClassName="ml-2"
|
||||
onClick={handleLogin}
|
||||
variant="secondary"
|
||||
className="relative w-full justify-center">
|
||||
{text}
|
||||
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { GithubIcon } from "@/modules/ui/components/icons";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
export const GithubButton = ({
|
||||
text = "Continue with Github",
|
||||
inviteUrl,
|
||||
lastUsed,
|
||||
}: {
|
||||
text?: string;
|
||||
inviteUrl?: string | null;
|
||||
lastUsed?: boolean;
|
||||
}) => {
|
||||
const t = useTranslations();
|
||||
const handleLogin = async () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Github");
|
||||
}
|
||||
await signIn("github", {
|
||||
redirect: true,
|
||||
callbackUrl: inviteUrl ? inviteUrl : "/", // redirect after login to /
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="base"
|
||||
type="button"
|
||||
EndIcon={GithubIcon}
|
||||
startIconClassName="ml-2"
|
||||
onClick={handleLogin}
|
||||
variant="secondary"
|
||||
className="relative w-full justify-center">
|
||||
{text}
|
||||
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { GoogleIcon } from "@/modules/ui/components/icons";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
export const GoogleButton = ({
|
||||
text = "Continue with Google",
|
||||
inviteUrl,
|
||||
lastUsed,
|
||||
}: {
|
||||
text?: string;
|
||||
inviteUrl?: string | null;
|
||||
lastUsed?: boolean;
|
||||
}) => {
|
||||
const t = useTranslations();
|
||||
const handleLogin = async () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Google");
|
||||
}
|
||||
await signIn("google", {
|
||||
redirect: true,
|
||||
callbackUrl: inviteUrl ? inviteUrl : "/", // redirect after login to /
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="base"
|
||||
type="button"
|
||||
EndIcon={GoogleIcon}
|
||||
startIconClassName="ml-3"
|
||||
onClick={handleLogin}
|
||||
variant="secondary"
|
||||
className="relative w-full justify-center">
|
||||
{text}
|
||||
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Validation {
|
||||
label: string;
|
||||
state: boolean;
|
||||
}
|
||||
|
||||
const PASSWORD_REGEX = {
|
||||
UPPER_AND_LOWER: /^(?=.*[A-Z])(?=.*[a-z])/,
|
||||
NUMBER: /\d/,
|
||||
};
|
||||
|
||||
const DEFAULT_VALIDATIONS = [
|
||||
{ label: "auth.signup.password_validation_uppercase_and_lowercase", state: false },
|
||||
{ label: "auth.signup.password_validation_minimum_8_and_maximum_128_characters", state: false },
|
||||
{ label: "auth.signup.password_validation_contain_at_least_1_number", state: false },
|
||||
];
|
||||
|
||||
export const IsPasswordValid = ({
|
||||
password,
|
||||
setIsValid,
|
||||
}: {
|
||||
password: string | null;
|
||||
setIsValid: (isValid: boolean) => void;
|
||||
}) => {
|
||||
const t = useTranslations();
|
||||
const [validations, setValidations] = useState<Validation[]>(DEFAULT_VALIDATIONS);
|
||||
|
||||
useEffect(() => {
|
||||
let newValidations = [...DEFAULT_VALIDATIONS];
|
||||
|
||||
const checkValidation = (prevValidations: Validation[], index: number, state: boolean) => {
|
||||
const updatedValidations = [...prevValidations];
|
||||
updatedValidations[index].state = state;
|
||||
return updatedValidations;
|
||||
};
|
||||
|
||||
if (password !== null) {
|
||||
newValidations = checkValidation(newValidations, 0, PASSWORD_REGEX.UPPER_AND_LOWER.test(password));
|
||||
newValidations = checkValidation(newValidations, 1, password.length >= 8 && password.length <= 128);
|
||||
newValidations = checkValidation(newValidations, 2, PASSWORD_REGEX.NUMBER.test(password));
|
||||
}
|
||||
setIsValid(newValidations.every((validation) => validation.state === true));
|
||||
setValidations(newValidations);
|
||||
}, [password, setIsValid]);
|
||||
|
||||
const renderIcon = (state: boolean) => {
|
||||
if (state === false) {
|
||||
return (
|
||||
<span className="flex h-5 w-5 items-center justify-center">
|
||||
<i className="inline-block h-2 w-2 rounded-full bg-slate-700"></i>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return <CheckIcon className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-2 text-left text-slate-700 sm:text-sm">
|
||||
<ul>
|
||||
{validations.map((validation, index) => (
|
||||
<li key={index}>
|
||||
<div className="flex items-center">
|
||||
{renderIcon(validation.state)}
|
||||
{t(validation.label)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
export const OpenIdButton = ({
|
||||
text = "Continue with OpenId Connect",
|
||||
inviteUrl,
|
||||
directRedirect = false,
|
||||
lastUsed,
|
||||
}: {
|
||||
text?: string;
|
||||
inviteUrl?: string | null;
|
||||
directRedirect?: boolean;
|
||||
lastUsed?: boolean;
|
||||
}) => {
|
||||
const t = useTranslations();
|
||||
const handleLogin = useCallback(async () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "OpenID");
|
||||
}
|
||||
await signIn("openid", {
|
||||
redirect: true,
|
||||
callbackUrl: inviteUrl ? inviteUrl : "/",
|
||||
});
|
||||
}, [inviteUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (directRedirect) {
|
||||
handleLogin();
|
||||
}
|
||||
}, [directRedirect, handleLogin]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="base"
|
||||
type="button"
|
||||
startIconClassName="ml-2"
|
||||
onClick={handleLogin}
|
||||
variant="secondary"
|
||||
className="relative w-full justify-center">
|
||||
{text}
|
||||
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,233 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createEmailTokenAction } from "@/modules/auth/components/SignupOptions/actions";
|
||||
import { AzureButton } from "@/modules/auth/components/SignupOptions/components/AzureButton";
|
||||
import { GithubButton } from "@/modules/auth/components/SignupOptions/components/GithubButton";
|
||||
import { GoogleButton } from "@/modules/auth/components/SignupOptions/components/GoogleButton";
|
||||
import { IsPasswordValid } from "@/modules/auth/components/SignupOptions/components/IsPasswordValid";
|
||||
import { OpenIdButton } from "@/modules/auth/components/SignupOptions/components/OpenIdButton";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { createUser } from "@formbricks/lib/utils/users";
|
||||
import { ZUserName } from "@formbricks/types/user";
|
||||
|
||||
interface SignupOptionsProps {
|
||||
emailAuthEnabled: boolean;
|
||||
emailFromSearchParams: string;
|
||||
setError?: (error: string) => void;
|
||||
emailVerificationDisabled: boolean;
|
||||
googleOAuthEnabled: boolean;
|
||||
githubOAuthEnabled: boolean;
|
||||
azureOAuthEnabled: boolean;
|
||||
oidcOAuthEnabled: boolean;
|
||||
inviteToken: string | null;
|
||||
callbackUrl: string;
|
||||
oidcDisplayName?: string;
|
||||
userLocale: string;
|
||||
}
|
||||
|
||||
export const SignupOptions = ({
|
||||
emailAuthEnabled,
|
||||
emailFromSearchParams,
|
||||
setError,
|
||||
emailVerificationDisabled,
|
||||
googleOAuthEnabled,
|
||||
githubOAuthEnabled,
|
||||
azureOAuthEnabled,
|
||||
oidcOAuthEnabled,
|
||||
inviteToken,
|
||||
callbackUrl,
|
||||
oidcDisplayName,
|
||||
userLocale,
|
||||
}: SignupOptionsProps) => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const [signingUp, setSigningUp] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const ZSignupInput = z.object({
|
||||
name: ZUserName,
|
||||
email: z.string().email(),
|
||||
password: z
|
||||
.string()
|
||||
.min(8)
|
||||
.regex(/^(?=.*[A-Z])(?=.*\d).*$/),
|
||||
});
|
||||
|
||||
type TSignupInput = z.infer<typeof ZSignupInput>;
|
||||
const form = useForm<TSignupInput>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: emailFromSearchParams || "",
|
||||
password: "",
|
||||
},
|
||||
resolver: zodResolver(ZSignupInput),
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = async (data: TSignupInput) => {
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSigningUp(true);
|
||||
|
||||
try {
|
||||
await createUser(data.name, data.email, data.password, userLocale, inviteToken || "");
|
||||
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
||||
if (emailTokenActionResponse?.serverError) {
|
||||
toast.error(emailTokenActionResponse.serverError);
|
||||
return;
|
||||
}
|
||||
const token = emailTokenActionResponse?.data;
|
||||
const url = emailVerificationDisabled
|
||||
? `/auth/signup-without-verification-success`
|
||||
: `/auth/verification-requested?token=${token}`;
|
||||
|
||||
router.push(url);
|
||||
} catch (e: any) {
|
||||
if (setError) {
|
||||
setError(e.message);
|
||||
}
|
||||
setSigningUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{emailAuthEnabled && (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
{showLogin && (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
value={field.value}
|
||||
name="name"
|
||||
autoFocus
|
||||
onChange={(name) => field.onChange(name)}
|
||||
placeholder="Full name"
|
||||
className="bg-white"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
value={field.value}
|
||||
name="email"
|
||||
onChange={(email) => field.onChange(email)}
|
||||
defaultValue={emailFromSearchParams}
|
||||
placeholder="work@email.com"
|
||||
className="bg-white"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md shadow-sm sm:text-sm"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<IsPasswordValid password={form.watch("password")} setIsValid={setIsValid} />
|
||||
</div>
|
||||
)}
|
||||
{showLogin && (
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-10 w-full justify-center"
|
||||
loading={signingUp}
|
||||
disabled={!form.formState.isValid}>
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!showLogin && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLogin(true);
|
||||
// Add a slight delay before focusing the input field to ensure it's visible
|
||||
setTimeout(() => nameRef.current?.focus(), 100);
|
||||
}}
|
||||
className="h-10 w-full justify-center">
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
{googleOAuthEnabled && (
|
||||
<>
|
||||
<GoogleButton inviteUrl={callbackUrl} text={t("auth.continue_with_google")} />
|
||||
</>
|
||||
)}
|
||||
{githubOAuthEnabled && (
|
||||
<>
|
||||
<GithubButton inviteUrl={callbackUrl} text={t("auth.continue_with_github")} />
|
||||
</>
|
||||
)}
|
||||
{azureOAuthEnabled && (
|
||||
<>
|
||||
<AzureButton inviteUrl={callbackUrl} text={t("auth.continue_with_azure")} />
|
||||
</>
|
||||
)}
|
||||
{oidcOAuthEnabled && (
|
||||
<>
|
||||
<OpenIdButton inviteUrl={callbackUrl} text={t("auth.continue_with_oidc", { oidcDisplayName })} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const BackToLoginButton = async () => {
|
||||
const t = await getTranslations();
|
||||
return (
|
||||
<Button size="base" variant="secondary" href="/auth/login" className="w-full justify-center">
|
||||
{t("auth.signup.log_in")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Logo } from "@/modules/ui/components/logo";
|
||||
|
||||
interface FormWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormWrapper = ({ children }: FormWrapperProps) => {
|
||||
return (
|
||||
<div className="mx-auto flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
|
||||
<div className="mx-auto w-full max-w-sm rounded-xl bg-white p-8 shadow-xl lg:w-96">
|
||||
<div className="mb-8 text-center">
|
||||
<Logo className="mx-auto w-3/4" />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import CalComLogo from "@/images/cal-logo-light.svg";
|
||||
import Peer from "@/images/peer.webp";
|
||||
import { CheckCircle2Icon } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Image from "next/image";
|
||||
|
||||
export const Testimonial = async () => {
|
||||
const t = await getTranslations();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center bg-gradient-to-tr from-slate-100 to-slate-300">
|
||||
<div className="3xl:w-2/3 mb-10 space-y-8 px-12 xl:px-20">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-slate-800">{t("auth.testimonial_title")}</h2>
|
||||
</div>
|
||||
{/* <p className="text-slate-600">
|
||||
Make customer-centric decisions based on data.
|
||||
<br /> Keep 100% data ownership.
|
||||
</p> */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex space-x-2">
|
||||
<CheckCircle2Icon className="text-brand-dark h-6 w-6" />
|
||||
<p className="inline text-lg text-slate-800">{t("auth.testimonial_all_features_included")}</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<CheckCircle2Icon className="text-brand-dark h-6 w-6" />
|
||||
<p className="inline text-lg text-slate-800">{t("auth.testimonial_free_and_open_source")}</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<CheckCircle2Icon className="text-brand-dark h-6 w-6" />
|
||||
<p className="inline text-lg text-slate-800">{t("auth.testimonial_no_credit_card_required")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-gradient-to-tr from-slate-100 to-slate-200 p-8">
|
||||
<p className="italic text-slate-700">{t("auth.testimonial_1")}</p>
|
||||
<div className="mt-4 flex items-center space-x-6">
|
||||
<Image
|
||||
src={Peer}
|
||||
alt="Cal.com Co-Founder Peer Richelsen"
|
||||
className="h-28 w-28 rounded-full border border-slate-200 shadow-sm"
|
||||
/>
|
||||
<div>
|
||||
<p className="mb-1.5 text-sm text-slate-500">Peer Richelsen, Co-Founder Cal.com</p>
|
||||
<Image src={CalComLogo} alt="Cal.com Logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user