mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
Merge branch 'formbricks:main' into feature/docs-in-page-section-nav
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
const LoadingCard = ({ title, description, skeletonLines }) => {
|
||||
return (
|
||||
<div className="my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
|
||||
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
|
||||
<h3 className="text-lg font-medium leading-6">{title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="rounded-lg px-6 py-5">
|
||||
{skeletonLines.map((line, index) => (
|
||||
<div key={index} className="mt-4">
|
||||
<div className={`animate-pulse rounded-full bg-slate-200 ${line.classes}`}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Loading = () => {
|
||||
const cards = [
|
||||
{
|
||||
title: "Email alerts (Surveys)",
|
||||
description: "Set up an alert to get an email on new responses.",
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
||||
},
|
||||
{
|
||||
title: "Weekly summary (Products)",
|
||||
description: "Stay up-to-date with a Weekly every Monday.",
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
||||
},
|
||||
];
|
||||
|
||||
const pages = ["Profile", "Notifications"];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-4 pb-4">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Account Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
|
||||
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
|
||||
{pages.map((navElem) => (
|
||||
<div
|
||||
key={navElem}
|
||||
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
|
||||
{navElem}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="justify-self-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
removeAvatarAction,
|
||||
updateAvatarAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
|
||||
import { handleFileUpload } from "@/app/lib/fileUpload";
|
||||
import { Session } from "next-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ProfileAvatar } from "@formbricks/ui/Avatars";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
export const EditAvatar = ({ session, environmentId }: { session: Session; environmentId: string }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleUpload = async (file: File, environmentId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (session?.user.imageUrl) {
|
||||
// If avatar image already exist, then remove it before update action
|
||||
await removeAvatarAction(environmentId);
|
||||
}
|
||||
const { url, error } = await handleFileUpload(file, environmentId);
|
||||
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateAvatarAction(url);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast.error("Avatar update failed. Please try again.");
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await removeAvatarAction(environmentId);
|
||||
} catch (err) {
|
||||
toast.error("Avatar update failed. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative h-10 w-10 overflow-hidden rounded-full">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
|
||||
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileAvatar userId={session.user.id} imageUrl={session.user.imageUrl} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
className="mr-2"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
inputRef.current?.click();
|
||||
}}>
|
||||
{session?.user.imageUrl ? "Change Image" : "Upload Image"}
|
||||
<input
|
||||
type="file"
|
||||
id="hiddenFileInput"
|
||||
ref={inputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
await handleUpload(file, environmentId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{session?.user?.imageUrl && (
|
||||
<Button className="mr-2" variant="warn" size="sm" onClick={handleRemove}>
|
||||
Remove Image
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { updateUserAction } from "../actions";
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const EditName = ({ user }: { user: TUser }) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
watch,
|
||||
} = useForm<FormData>();
|
||||
|
||||
const nameValue = watch("name", user.name || "");
|
||||
const isNotEmptySpaces = (value: string) => value.trim() !== "";
|
||||
|
||||
const onSubmit: SubmitHandler<FormData> = async (data) => {
|
||||
try {
|
||||
data.name = data.name.trim();
|
||||
if (!isNotEmptySpaces(data.name)) {
|
||||
toast.error("Please enter at least one character");
|
||||
return;
|
||||
}
|
||||
if (data.name === user.name) {
|
||||
toast.success("This is already your name");
|
||||
return;
|
||||
}
|
||||
await updateUserAction({ name: data.name });
|
||||
toast.success("Your name was updated successfully");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="w-full max-w-sm items-center" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Label htmlFor="fullname">Full Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="fullname"
|
||||
defaultValue={user.name || ""}
|
||||
{...register("name", { required: true })}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input type="email" id="fullname" defaultValue={user.email} disabled />
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={nameValue === "" || isSubmitting}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
removeAvatarAction,
|
||||
updateAvatarAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
|
||||
import { handleFileUpload } from "@/app/lib/fileUpload";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Session } from "next-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ProfileAvatar } from "@formbricks/ui/Avatars";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormError, FormField, FormItem, FormProvider } from "@formbricks/ui/Form";
|
||||
|
||||
interface EditProfileAvatarFormProps {
|
||||
session: Session;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const EditProfileAvatarForm = ({ session, environmentId }: EditProfileAvatarFormProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const fileSchema =
|
||||
typeof window !== "undefined"
|
||||
? z
|
||||
.instanceof(FileList)
|
||||
.refine((files) => files.length === 1, "You must select a file.")
|
||||
.refine((files) => {
|
||||
const file = files[0];
|
||||
const allowedTypes = ["image/jpeg", "image/png"];
|
||||
return allowedTypes.includes(file.type);
|
||||
}, "Invalid file type. Only JPEG and PNG are allowed.")
|
||||
.refine((files) => {
|
||||
const file = files[0];
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
return file.size <= maxSize;
|
||||
}, "File size must be less than 10MB.")
|
||||
: z.any();
|
||||
|
||||
const formSchema = z.object({
|
||||
file: fileSchema,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const handleUpload = async (file: File, environmentId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (session?.user.imageUrl) {
|
||||
// If avatar image already exists, then remove it before update action
|
||||
await removeAvatarAction(environmentId);
|
||||
}
|
||||
const { url, error } = await handleFileUpload(file, environmentId);
|
||||
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateAvatarAction(url);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast.error("Avatar update failed. Please try again.");
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await removeAvatarAction(environmentId);
|
||||
} catch (err) {
|
||||
toast.error("Avatar update failed. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const file = data.file[0];
|
||||
if (file) {
|
||||
await handleUpload(file, environmentId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative h-10 w-10 overflow-hidden rounded-full">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
|
||||
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileAvatar userId={session.user.id} imageUrl={session.user.imageUrl} />
|
||||
</div>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4">
|
||||
<FormField
|
||||
name="file"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<div className="flex">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mr-2"
|
||||
variant={!!fieldState.error?.message ? "warn" : "secondary"}
|
||||
onClick={() => {
|
||||
inputRef.current?.click();
|
||||
}}>
|
||||
{session?.user.imageUrl ? "Change Image" : "Upload Image"}
|
||||
<input
|
||||
type="file"
|
||||
id="hiddenFileInput"
|
||||
ref={(e) => {
|
||||
field.ref(e);
|
||||
// @ts-expect-error
|
||||
inputRef.current = e;
|
||||
}}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.files);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{session?.user?.imageUrl && (
|
||||
<Button type="button" className="mr-2" variant="warn" size="sm" onClick={handleRemove}>
|
||||
Remove Image
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TUser, ZUser } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { updateUserAction } from "../actions";
|
||||
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true });
|
||||
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
|
||||
|
||||
export const EditProfileDetailsForm = ({ user }: { user: TUser }) => {
|
||||
const form = useForm<TEditProfileNameForm>({
|
||||
defaultValues: { name: user.name },
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZEditProfileNameFormSchema),
|
||||
});
|
||||
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
|
||||
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
|
||||
try {
|
||||
const name = data.name.trim();
|
||||
await updateUserAction({ name });
|
||||
toast.success("Your name was updated successfully");
|
||||
|
||||
form.reset({ name });
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full max-w-sm items-center" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
placeholder="Full Name"
|
||||
required
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* disabled */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input type="email" id="fullname" defaultValue={user.email} disabled />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
const LoadingCard = ({ title, description, skeletonLines }) => {
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
|
||||
<div className="my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
|
||||
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
|
||||
<h3 className="text-lg font-medium leading-6">{title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
|
||||
<div className="rounded-lg px-6 py-5">
|
||||
{skeletonLines.map((line, index) => (
|
||||
<div key={index} className="mt-4">
|
||||
<div className={`animate-pulse rounded-full bg-slate-200 ${line.classes}`}></div>
|
||||
@@ -28,7 +28,6 @@ const Loading = () => {
|
||||
{ classes: "h-6 w-64" },
|
||||
{ classes: "h-4 w-28" },
|
||||
{ classes: "h-6 w-64" },
|
||||
{ classes: "h-8 w-24" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -48,9 +47,29 @@ const Loading = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const pages = ["Profile", "Notifications"];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Profile</h2>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-4 pb-4">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Account Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
|
||||
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
|
||||
{pages.map((navElem) => (
|
||||
<div
|
||||
key={navElem}
|
||||
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
|
||||
{navElem}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="justify-self-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
|
||||
@@ -11,8 +11,8 @@ import { SettingsId } from "@formbricks/ui/SettingsId";
|
||||
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteAccount } from "./components/DeleteAccount";
|
||||
import { EditAvatar } from "./components/EditAvatar";
|
||||
import { EditName } from "./components/EditName";
|
||||
import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm";
|
||||
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||
|
||||
const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
const { environmentId } = params;
|
||||
@@ -30,12 +30,12 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
{user && (
|
||||
<div>
|
||||
<SettingsCard title="Personal information" description="Update your personal information.">
|
||||
<EditName user={user} />
|
||||
<EditProfileDetailsForm user={user} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Avatar"
|
||||
description="Assist your organization in identifying you on Formbricks.">
|
||||
<EditAvatar session={session} environmentId={environmentId} />
|
||||
<EditProfileAvatarForm session={session} environmentId={environmentId} />
|
||||
</SettingsCard>
|
||||
{user.identityProvider === "email" && (
|
||||
<SettingsCard title="Security" description="Manage your password and other security settings.">
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
const pages = ["Members", "Billing & Plan"];
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Billing & Plan</h2>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg p-8">
|
||||
<div className=" h-[75vh] animate-pulse rounded-md bg-slate-200 "></div>
|
||||
<div className=" h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||
<div className="col-span-2 h-96 bg-slate-200 p-8"></div>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-4 pb-4">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Organization Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
|
||||
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
|
||||
{pages.map((navElem) => (
|
||||
<div
|
||||
key={navElem}
|
||||
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
|
||||
{navElem}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="justify-self-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" my-8 h-64 animate-pulse rounded-xl bg-slate-200 "></div>
|
||||
<div className=" my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
const pages = ["Members", "Enterprise License"];
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Enterprise License</h2>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg p-8">
|
||||
<div className=" h-[75vh] animate-pulse rounded-md bg-slate-200 "></div>
|
||||
<div className=" h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||
<div className="col-span-2 h-96 bg-slate-200 p-8"></div>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-4 pb-4">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Organization Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
|
||||
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
|
||||
{pages.map((navElem) => (
|
||||
<div
|
||||
key={navElem}
|
||||
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
|
||||
{navElem}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="justify-self-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" my-8 h-64 animate-pulse rounded-xl bg-slate-200 "></div>
|
||||
<div className=" my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { SubmitHandler, useForm, useWatch } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
interface EditOrganizationNameForm {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface EditOrganizationNameProps {
|
||||
environmentId: string;
|
||||
organization: TOrganization;
|
||||
membershipRole?: TMembershipRole;
|
||||
}
|
||||
|
||||
export const EditOrganizationName = ({ organization, membershipRole }: EditOrganizationNameProps) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<EditOrganizationNameForm>({
|
||||
defaultValues: {
|
||||
name: organization.name,
|
||||
},
|
||||
});
|
||||
const [isUpdatingOrganization, setIsUpdatingOrganization] = useState(false);
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
|
||||
const organizationName = useWatch({
|
||||
control,
|
||||
name: "name",
|
||||
});
|
||||
|
||||
const isOrganizationNameInputEmpty = !organizationName?.trim();
|
||||
const currentOrganizationName = organizationName?.trim().toLowerCase() ?? "";
|
||||
const previousOrganizationName = organization?.name?.trim().toLowerCase() ?? "";
|
||||
|
||||
const handleUpdateOrganizationName: SubmitHandler<EditOrganizationNameForm> = async (data) => {
|
||||
try {
|
||||
data.name = data.name.trim();
|
||||
setIsUpdatingOrganization(true);
|
||||
await updateOrganizationNameAction(organization.id, data.name);
|
||||
|
||||
setIsUpdatingOrganization(false);
|
||||
toast.success("Organization name updated successfully.");
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setIsUpdatingOrganization(false);
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return isViewer ? (
|
||||
<p className="text-sm text-red-700">You are not authorized to perform this action.</p>
|
||||
) : (
|
||||
<form className="w-full max-w-sm items-center" onSubmit={handleSubmit(handleUpdateOrganizationName)}>
|
||||
<Label htmlFor="organizationname">Organization Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="organizationname"
|
||||
defaultValue={organization?.name ?? ""}
|
||||
{...register("name", {
|
||||
required: {
|
||||
message: "Organization name is required.",
|
||||
value: true,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{errors?.name?.message && <p className="text-xs text-red-500">{errors.name.message}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
loading={isUpdatingOrganization}
|
||||
disabled={isOrganizationNameInputEmpty || currentOrganizationName === previousOrganizationName}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/actions";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
interface EditOrganizationNameProps {
|
||||
environmentId: string;
|
||||
organization: TOrganization;
|
||||
membershipRole?: TMembershipRole;
|
||||
}
|
||||
|
||||
const ZEditOrganizationNameFormSchema = ZOrganization.pick({ name: true });
|
||||
type EditOrganizationNameForm = z.infer<typeof ZEditOrganizationNameFormSchema>;
|
||||
|
||||
export const EditOrganizationNameForm = ({ organization, membershipRole }: EditOrganizationNameProps) => {
|
||||
const form = useForm<EditOrganizationNameForm>({
|
||||
defaultValues: {
|
||||
name: organization.name,
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZEditOrganizationNameFormSchema),
|
||||
});
|
||||
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
|
||||
const handleUpdateOrganizationName: SubmitHandler<EditOrganizationNameForm> = async (data) => {
|
||||
try {
|
||||
const name = data.name.trim();
|
||||
const updatedOrg = await updateOrganizationNameAction(organization.id, name);
|
||||
|
||||
toast.success("Organization name updated successfully.");
|
||||
form.reset({ name: updatedOrg.name });
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return isViewer ? (
|
||||
<p className="text-sm text-red-700">You are not authorized to perform this action.</p>
|
||||
) : (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
className="w-full max-w-sm items-center"
|
||||
onSubmit={form.handleSubmit(handleUpdateOrganizationName)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
isInvalid={!!fieldState.error?.message}
|
||||
placeholder="Organization Name"
|
||||
required
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,77 +1,68 @@
|
||||
import { Skeleton } from "@formbricks/ui/Skeleton";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
|
||||
const LoadingCard = ({
|
||||
title,
|
||||
description,
|
||||
skeleton,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
skeleton: React.ReactNode;
|
||||
}) => {
|
||||
const LoadingCard = ({ title, description, skeletonLines }) => {
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
|
||||
<div className="my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
|
||||
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
|
||||
<h3 className="text-lg font-medium leading-6">{title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
<div className="w-full">{skeleton}</div>
|
||||
<div className="w-full">
|
||||
<div className="rounded-lg px-6">
|
||||
{skeletonLines.map((line, index) => (
|
||||
<div key={index} className="mt-4">
|
||||
<div className={`animate-pulse rounded-full bg-slate-200 ${line.classes}`}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Manage members",
|
||||
description: "Add or remove members in your organization.",
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }, { classes: "h-8 w-80" }],
|
||||
},
|
||||
{
|
||||
title: "Organization Name",
|
||||
description: "Give your organization a descriptive name.",
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
|
||||
},
|
||||
{
|
||||
title: "Delete Organization",
|
||||
description:
|
||||
"Delete organization with all its products including all surveys, responses, people, actions and attributes",
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
|
||||
},
|
||||
];
|
||||
|
||||
const pages = ["Members", IS_FORMBRICKS_CLOUD ? "Billing & Plan" : "Enterprise License"];
|
||||
|
||||
const Loading = () => {
|
||||
const cards = [
|
||||
{
|
||||
title: "Manage members",
|
||||
description: "Add or remove members in your organization",
|
||||
skeleton: (
|
||||
<div className="flex flex-col space-y-4 p-4">
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<Skeleton className="h-12 w-40 rounded-lg" />
|
||||
<Skeleton className="h-12 w-40 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2"></div>
|
||||
<div className="col-span-5">Fullname</div>
|
||||
<div className="col-span-5">Email</div>
|
||||
<div className="col-span-3">Role</div>
|
||||
<div className="col-span-5"></div>
|
||||
</div>
|
||||
|
||||
<div className="h-10"></div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Organization Name",
|
||||
description: "Give your organization a descriptive name",
|
||||
skeleton: (
|
||||
<div className="flex flex-col p-4">
|
||||
<Skeleton className="mb-2 h-5 w-32" />
|
||||
<Skeleton className="mb-4 h-12 w-96 rounded-lg" />
|
||||
<Skeleton className="h-12 w-36 rounded-lg" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Delete account",
|
||||
description: "Delete your account with all of your personal information and data.",
|
||||
skeleton: (
|
||||
<div className="flex flex-col p-4">
|
||||
<Skeleton className="mb-2 h-5 w-full" />
|
||||
<Skeleton className="h-12 w-36 rounded-lg" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Profile</h2>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-4 pb-4">
|
||||
<h1 className="text-3xl font-bold text-slate-800">Organization Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
|
||||
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
|
||||
{pages.map((navElem) => (
|
||||
<div
|
||||
key={navElem}
|
||||
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
|
||||
{navElem}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="justify-self-end"></div>
|
||||
</div>
|
||||
</div>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
|
||||
@@ -15,34 +15,19 @@ import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/ser
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
import { SettingsId } from "@formbricks/ui/SettingsId";
|
||||
import { Skeleton } from "@formbricks/ui/Skeleton";
|
||||
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditMemberships } from "./components/EditMemberships";
|
||||
import { EditOrganizationName } from "./components/EditOrganizationName";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
|
||||
const MembersLoading = () => (
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2"></div>
|
||||
<div className="col-span-5">Fullname</div>
|
||||
<div className="col-span-5">Email</div>
|
||||
<div className="col-span-3">Role</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-white p-4 text-left text-sm font-semibold text-slate-900">
|
||||
<Skeleton className="col-span-2 h-10 w-10 rounded-full" />
|
||||
<Skeleton className="col-span-5 h-8 w-24" />
|
||||
<Skeleton className="col-span-5 h-8 w-24" />
|
||||
<Skeleton className="col-span-3 h-8 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-2">
|
||||
{Array.from(Array(2)).map((_, index) => (
|
||||
<div key={index} className="mt-4">
|
||||
<div className={`h-8 w-80 animate-pulse rounded-full bg-slate-200`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -104,7 +89,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
)}
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Organization Name" description="Give your organization a descriptive name.">
|
||||
<EditOrganizationName
|
||||
<EditOrganizationNameForm
|
||||
organization={organization}
|
||||
environmentId={params.environmentId}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "name" SET NOT NULL;
|
||||
@@ -569,7 +569,7 @@ model User {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String?
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified DateTime? @map(name: "email_verified")
|
||||
imageUrl String?
|
||||
|
||||
@@ -27,7 +27,10 @@ export const ZOrganization = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
name: z.string(),
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name must be at least 1 character long" }),
|
||||
billing: ZOrganizationBilling,
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings
|
||||
|
||||
export const ZUser = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" }),
|
||||
email: z.string().email(),
|
||||
emailVerified: z.date().nullable(),
|
||||
imageUrl: z.string().url().nullable(),
|
||||
@@ -40,7 +43,7 @@ export const ZUser = z.object({
|
||||
export type TUser = z.infer<typeof ZUser>;
|
||||
|
||||
export const ZUserUpdateInput = z.object({
|
||||
name: z.string().nullish(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
emailVerified: z.date().nullish(),
|
||||
onboardingCompleted: z.boolean().optional(),
|
||||
@@ -53,7 +56,10 @@ export const ZUserUpdateInput = z.object({
|
||||
export type TUserUpdateInput = z.infer<typeof ZUserUpdateInput>;
|
||||
|
||||
export const ZUserCreateInput = z.object({
|
||||
name: z.string().optional(),
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" }),
|
||||
email: z.string().email(),
|
||||
emailVerified: z.date().optional(),
|
||||
onboardingCompleted: z.boolean().optional(),
|
||||
|
||||
Reference in New Issue
Block a user