mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-20 13:59:23 -06:00
fix: security checks for user input fields for email (#3819)
This commit is contained in:
committed by
GitHub
parent
da6f54eede
commit
e7edfe3ba1
@@ -19,7 +19,11 @@ interface InviteOrganizationMemberProps {
|
||||
|
||||
const ZInviteOrganizationMemberDetails = z.object({
|
||||
email: z.string().email(),
|
||||
inviteMessage: z.string().trim().min(1),
|
||||
inviteMessage: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => !/https?:\/\/|<script/i.test(value), "Invite message cannot contain URLs or scripts"),
|
||||
});
|
||||
type TInviteOrganizationMemberDetails = z.infer<typeof ZInviteOrganizationMemberDetails>;
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { AddMemberRole } from "@/modules/ee/role-management/components/add-member-role";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { OrganizationRole } from "@prisma/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { z } from "zod";
|
||||
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { ZUserName } from "@formbricks/types/user";
|
||||
import { Alert, AlertDescription } from "@formbricks/ui/components/Alert";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
@@ -18,6 +21,7 @@ interface IndividualInviteTabProps {
|
||||
isFormbricksCloud: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const IndividualInviteTab = ({
|
||||
setOpen,
|
||||
onSubmit,
|
||||
@@ -25,6 +29,13 @@ export const IndividualInviteTab = ({
|
||||
isFormbricksCloud,
|
||||
environmentId,
|
||||
}: IndividualInviteTabProps) => {
|
||||
const ZFormSchema = z.object({
|
||||
name: ZUserName,
|
||||
email: z.string().email("Invalid email address"),
|
||||
role: ZOrganizationRole,
|
||||
});
|
||||
|
||||
type TFormData = z.infer<typeof ZFormSchema>;
|
||||
const t = useTranslations();
|
||||
const {
|
||||
register,
|
||||
@@ -33,12 +44,13 @@ export const IndividualInviteTab = ({
|
||||
reset,
|
||||
control,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<{
|
||||
name: string;
|
||||
email: string;
|
||||
role: TOrganizationRole;
|
||||
}>();
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<TFormData>({
|
||||
resolver: zodResolver(ZFormSchema),
|
||||
defaultValues: {
|
||||
role: "owner",
|
||||
},
|
||||
});
|
||||
|
||||
const submitEventClass = async () => {
|
||||
const data = getValues();
|
||||
@@ -55,9 +67,10 @@ export const IndividualInviteTab = ({
|
||||
<Label htmlFor="memberNameInput">{t("common.full_name")}</Label>
|
||||
<Input
|
||||
id="memberNameInput"
|
||||
placeholder="e.g. Hans Wurst"
|
||||
placeholder="Hans Wurst"
|
||||
{...register("name", { required: true, validate: (value) => value.trim() !== "" })}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="memberEmailInput">{t("common.email")}</Label>
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
|
||||
import { TwoFactor } from "@/app/(auth)/auth/login/components/TwoFactor";
|
||||
import { TwoFactorBackup } from "@/app/(auth)/auth/login/components/TwoFactorBackup";
|
||||
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 { Controller, FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
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";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@formbricks/ui/components/Form";
|
||||
import { PasswordInput } from "@formbricks/ui/components/PasswordInput";
|
||||
import { AzureButton } from "@formbricks/ui/components/SignupOptions/components/AzureButton";
|
||||
import { GithubButton } from "@formbricks/ui/components/SignupOptions/components/GithubButton";
|
||||
@@ -53,6 +56,23 @@ export const SigninForm = ({
|
||||
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);
|
||||
@@ -137,11 +157,11 @@ export const SigninForm = ({
|
||||
|
||||
const TwoFactorComponent = useMemo(() => {
|
||||
if (totpBackup) {
|
||||
return <TwoFactorBackup />;
|
||||
return <TwoFactorBackup form={form} />;
|
||||
}
|
||||
|
||||
if (totpLogin) {
|
||||
return <TwoFactor />;
|
||||
return <TwoFactor form={form} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -153,53 +173,58 @@ export const SigninForm = ({
|
||||
<h1 className="mb-4 text-slate-700">{formLabel}</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
<form onSubmit={formMethods.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
{TwoFactorComponent}
|
||||
|
||||
{showLogin && (
|
||||
<div className={cn(totpLogin && "hidden")}>
|
||||
<div className="mb-2 transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="email" className="sr-only">
|
||||
{t("common.email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
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"
|
||||
{...formMethods.register("email", {
|
||||
required: true,
|
||||
pattern: /\S+@\S+\.\S+/,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="password" className="sr-only">
|
||||
{t("common.password")}
|
||||
</label>
|
||||
<Controller
|
||||
name="password"
|
||||
control={formMethods.control}
|
||||
render={({ field }) => (
|
||||
<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"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
|
||||
@@ -2,11 +2,24 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormField, FormItem } from "@formbricks/ui/components/Form";
|
||||
import { OTPInput } from "@formbricks/ui/components/OTPInput";
|
||||
|
||||
export const TwoFactor = () => {
|
||||
const { control } = useFormContext();
|
||||
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 (
|
||||
@@ -16,11 +29,15 @@ export const TwoFactor = () => {
|
||||
{t("auth.login.enter_your_two_factor_authentication_code")}
|
||||
</label>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="totpCode"
|
||||
render={({ field }) => (
|
||||
<OTPInput value={field.value ?? ""} onChange={field.onChange} valueLength={6} />
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<OTPInput value={field.value ?? ""} onChange={field.onChange} valueLength={6} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,25 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { FormField, FormItem } from "@formbricks/ui/components/Form";
|
||||
import { FormControl } from "@formbricks/ui/components/Form";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
|
||||
export const TwoFactorBackup = () => {
|
||||
const { register } = useFormContext();
|
||||
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 (
|
||||
@@ -15,12 +29,23 @@ export const TwoFactorBackup = () => {
|
||||
<label htmlFor="totpBackup" className="sr-only">
|
||||
{t("auth.login.backup_code")}
|
||||
</label>
|
||||
<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"
|
||||
{...register("backupCode")}
|
||||
<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>
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DEFAULT_ORGANIZATION_ID,
|
||||
DEFAULT_ORGANIZATION_ROLE,
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENCRYPTION_KEY,
|
||||
GITHUB_ID,
|
||||
GITHUB_SECRET,
|
||||
GOOGLE_CLIENT_ID,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
OIDC_ISSUER,
|
||||
OIDC_SIGNING_ALGORITHM,
|
||||
} from "./constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "./crypto";
|
||||
import { verifyToken } from "./jwt";
|
||||
import { createMembership } from "./membership/service";
|
||||
import { createOrganization, getOrganization } from "./organization/service";
|
||||
@@ -52,6 +54,8 @@ export const authOptions: NextAuthOptions = {
|
||||
type: "password",
|
||||
placeholder: "Your password",
|
||||
},
|
||||
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
|
||||
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
let user;
|
||||
@@ -79,6 +83,54 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled && credentials.backupCode) {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
console.error("Missing encryption key; cannot proceed with backup code login.");
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
if (!user.backupCodes) throw new Error("No backup codes found");
|
||||
|
||||
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, ENCRYPTION_KEY));
|
||||
|
||||
// check if user-supplied code matches one
|
||||
const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", ""));
|
||||
if (index === -1) throw new Error("Invalid backup code");
|
||||
|
||||
// delete verified backup code and re-encrypt remaining
|
||||
backupCodes[index] = null;
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), ENCRYPTION_KEY),
|
||||
},
|
||||
});
|
||||
} else if (user.twoFactorEnabled) {
|
||||
if (!credentials.totpCode) {
|
||||
throw new Error("second factor required");
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
const secret = symmetricDecrypt(user.twoFactorSecret, ENCRYPTION_KEY);
|
||||
if (secret.length !== 32) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
const isValidToken = (await import("./totp")).totpAuthenticatorCheck(credentials.totpCode, secret);
|
||||
if (!isValidToken) {
|
||||
throw new Error("Invalid second factor code");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
|
||||
@@ -350,6 +350,7 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
||||
"styling": "Styling",
|
||||
"submit": "Abschicken",
|
||||
"summary": "Zusammenfassung",
|
||||
"survey": "Umfrage",
|
||||
"survey_completed": "Umfrage abgeschlossen.",
|
||||
@@ -1133,6 +1134,7 @@
|
||||
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
|
||||
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
|
||||
"invalid_file_type": "Ungültiger Dateityp. Nur JPEG-, PNG- und WEBP-Dateien sind erlaubt.",
|
||||
|
||||
@@ -350,6 +350,7 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Step by step manual",
|
||||
"styling": "Styling",
|
||||
"submit": "Submit",
|
||||
"summary": "Summary",
|
||||
"survey": "Survey",
|
||||
"survey_completed": "Survey completed.",
|
||||
@@ -1133,6 +1134,7 @@
|
||||
"disable_two_factor_authentication": "Disable two factor authentication",
|
||||
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
|
||||
"invalid_file_type": "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.",
|
||||
|
||||
@@ -350,6 +350,7 @@
|
||||
"status": "status",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"styling": "estilização",
|
||||
"submit": "Enviar",
|
||||
"summary": "Resumo",
|
||||
"survey": "Pesquisa",
|
||||
"survey_completed": "Pesquisa concluída.",
|
||||
@@ -1133,6 +1134,7 @@
|
||||
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
|
||||
"invalid_file_type": "Tipo de arquivo inválido. Só são permitidos arquivos JPEG, PNG e WEBP.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { ZOrganizationRole } from "./memberships";
|
||||
import { ZUserName } from "./user";
|
||||
|
||||
export const ZInvite = z.object({
|
||||
id: z.string(),
|
||||
@@ -16,7 +17,7 @@ export type TInvite = z.infer<typeof ZInvite>;
|
||||
|
||||
export const ZInvitee = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
name: ZUserName,
|
||||
role: ZOrganizationRole,
|
||||
});
|
||||
export type TInvitee = z.infer<typeof ZInvitee>;
|
||||
|
||||
@@ -22,14 +22,17 @@ export const ZUserNotificationSettings = z.object({
|
||||
unsubscribedOrganizationIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ZUserName = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" })
|
||||
.regex(/^[a-zA-Z0-9\s]+$/, { message: "Name should only contain letters, numbers, and spaces" });
|
||||
|
||||
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;
|
||||
|
||||
export const ZUser = z.object({
|
||||
id: z.string(),
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" }),
|
||||
name: ZUserName,
|
||||
email: z.string().email(),
|
||||
emailVerified: z.date().nullable(),
|
||||
imageUrl: z.string().url().nullable(),
|
||||
@@ -46,7 +49,7 @@ export const ZUser = z.object({
|
||||
export type TUser = z.infer<typeof ZUser>;
|
||||
|
||||
export const ZUserUpdateInput = z.object({
|
||||
name: z.string().optional(),
|
||||
name: ZUserName.optional(),
|
||||
email: z.string().email().optional(),
|
||||
emailVerified: z.date().nullish(),
|
||||
role: ZRole.optional(),
|
||||
@@ -59,10 +62,7 @@ export const ZUserUpdateInput = z.object({
|
||||
export type TUserUpdateInput = z.infer<typeof ZUserUpdateInput>;
|
||||
|
||||
export const ZUserCreateInput = z.object({
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" }),
|
||||
name: ZUserName,
|
||||
email: z.string().email(),
|
||||
emailVerified: z.date().optional(),
|
||||
role: ZRole.optional(),
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
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 { z } from "zod";
|
||||
import { createUser } from "@formbricks/lib/utils/users";
|
||||
import { ZUserName } from "@formbricks/types/user";
|
||||
import { Button } from "../Button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "../Form";
|
||||
import { Input } from "../Input";
|
||||
import { PasswordInput } from "../PasswordInput";
|
||||
import { AzureButton } from "./components/AzureButton";
|
||||
import { GithubButton } from "./components/GithubButton";
|
||||
@@ -41,28 +47,35 @@ export const SignupOptions = ({
|
||||
oidcDisplayName,
|
||||
userLocale,
|
||||
}: SignupOptionsProps) => {
|
||||
const t = useTranslations();
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const [signingUp, setSigningUp] = useState(false);
|
||||
const [isButtonEnabled, setButtonEnabled] = useState(true);
|
||||
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 formRef = useRef<HTMLFormElement>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const checkFormValidity = () => {
|
||||
// If all fields are filled, enable the button
|
||||
if (formRef.current) {
|
||||
setButtonEnabled(formRef.current.checkValidity());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
const handleSubmit = async (data: TSignupInput) => {
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
@@ -70,16 +83,10 @@ export const SignupOptions = ({
|
||||
setSigningUp(true);
|
||||
|
||||
try {
|
||||
await createUser(
|
||||
e.target.elements.name.value,
|
||||
e.target.elements.email.value,
|
||||
e.target.elements.password.value,
|
||||
userLocale,
|
||||
inviteToken || ""
|
||||
);
|
||||
await createUser(data.name, data.email, data.password, userLocale, inviteToken || "");
|
||||
const url = emailVerificationDisabled
|
||||
? `/auth/signup-without-verification-success`
|
||||
: `/auth/verification-requested?email=${encodeURIComponent(e.target.elements.email.value)}`;
|
||||
: `/auth/verification-requested?email=${encodeURIComponent(data.email)}`;
|
||||
|
||||
router.push(url);
|
||||
} catch (e: any) {
|
||||
@@ -93,87 +100,105 @@ export const SignupOptions = ({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{emailAuthEnabled && (
|
||||
<form onSubmit={handleSubmit} ref={formRef} className="space-y-2" onChange={checkFormValidity}>
|
||||
{showLogin && (
|
||||
<div>
|
||||
<div className="mb-2 transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="name" className="sr-only">
|
||||
{t("common.full_name")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
ref={nameRef}
|
||||
id="name"
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
{showLogin && (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder={t("common.full_name")}
|
||||
aria-placeholder={"Full name"}
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
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>
|
||||
<div className="mb-2 transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="email" className="sr-only">
|
||||
{t("common.email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="work@email.com"
|
||||
defaultValue={emailFromSearchParams}
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="password" className="sr-only">
|
||||
{t("common.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
value={password ? password : ""}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<IsPasswordValid password={password} setIsValid={setIsValid} />
|
||||
</div>
|
||||
)}
|
||||
{showLogin && (
|
||||
<Button
|
||||
size="base"
|
||||
type="submit"
|
||||
className="w-full justify-center"
|
||||
loading={signingUp}
|
||||
disabled={formRef.current ? !isButtonEnabled || !isValid : !isButtonEnabled}>
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
{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
|
||||
size="base"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLogin(true);
|
||||
setButtonEnabled(false);
|
||||
// Add a slight delay before focusing the input field to ensure it's visible
|
||||
setTimeout(() => nameRef.current?.focus(), 100);
|
||||
}}
|
||||
className="w-full justify-center">
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
{!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 && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user