mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-08 06:41:45 -05:00
feat: Onboarding for self hosting (#2722)
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
9888d128a2
commit
5bf5825f30
@@ -0,0 +1,31 @@
|
||||
import { Metadata } from "next";
|
||||
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Intro",
|
||||
description: "Open-source Experience Management. Free & open source.",
|
||||
};
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h2 className="mb-6 text-xl font-medium">Welcome to Formbricks!</h2>
|
||||
<div className="space-y-4 text-sm text-slate-800">
|
||||
<p>
|
||||
Formbricks is a versatile open source platform with an Experience Management Suite built on top of
|
||||
it.
|
||||
</p>
|
||||
<p>Survey customers, users or employees at any points with a perfectly timed and targeted survey.</p>
|
||||
<p>Keep full control over your data</p>
|
||||
</div>
|
||||
<Button variant="darkCTA" href="/setup/signup" className="mt-6">
|
||||
Get started
|
||||
</Button>
|
||||
|
||||
<p className="pt-6 text-xs text-slate-500">Made with 🤍 in Kiel, Germany</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getIsFreshInstance } from "@formbricks/lib/instance/service";
|
||||
|
||||
const FreshInstanceLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const isFreshInstance = await getIsFreshInstance();
|
||||
|
||||
if (session || !isFreshInstance) {
|
||||
return notFound();
|
||||
}
|
||||
if (!isFreshInstance) return notFound();
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default FreshInstanceLayout;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Metadata } from "next";
|
||||
|
||||
import {
|
||||
AZURE_OAUTH_ENABLED,
|
||||
EMAIL_AUTH_ENABLED,
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
PASSWORD_RESET_DISABLED,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { SignupOptions } from "@formbricks/ui/SignupOptions";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign up",
|
||||
description: "Open-source Experience Management. Free & open source.",
|
||||
};
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h2 className="mb-6 text-xl font-medium">Create Administrator</h2>
|
||||
<p className="text-sm text-slate-800">This user has all the power.</p>
|
||||
<hr className="my-6 w-full border-slate-200" />
|
||||
<SignupOptions
|
||||
emailAuthEnabled={EMAIL_AUTH_ENABLED}
|
||||
emailFromSearchParams={""}
|
||||
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
|
||||
passwordResetEnabled={!PASSWORD_RESET_DISABLED}
|
||||
googleOAuthEnabled={GOOGLE_OAUTH_ENABLED}
|
||||
githubOAuthEnabled={GITHUB_OAUTH_ENABLED}
|
||||
azureOAuthEnabled={AZURE_OAUTH_ENABLED}
|
||||
oidcOAuthEnabled={OIDC_OAUTH_ENABLED}
|
||||
inviteToken={""}
|
||||
callbackUrl={""}
|
||||
oidcDisplayName={OIDC_DISPLAY_NAME}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { FormbricksLogo } from "@formbricks/ui/FormbricksLogo";
|
||||
|
||||
const SetupLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
<div className="flex h-full w-full items-center justify-center bg-slate-50">
|
||||
<div
|
||||
style={{ scrollbarGutter: "stable both-edges" }}
|
||||
className="flex max-h-[90vh] w-[40rem] flex-col items-center space-y-4 overflow-auto rounded-lg border bg-white p-12 text-center shadow">
|
||||
<div className="h-20 w-20 rounded-lg bg-slate-900 p-2">
|
||||
<FormbricksLogo className="h-full w-full" />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupLayout;
|
||||
@@ -0,0 +1,50 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { sendInviteMemberEmail } from "@formbricks/email";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { INVITE_DISABLED } from "@formbricks/lib/constants";
|
||||
import { inviteUser } from "@formbricks/lib/invite/service";
|
||||
import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
|
||||
import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
|
||||
export const inviteOrganizationMemberAction = async (email: string, organizationId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
||||
|
||||
if (INVITE_DISABLED) {
|
||||
throw new AuthenticationError("Invite disabled");
|
||||
}
|
||||
|
||||
const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(organizationId, session.user.id);
|
||||
if (!hasCreateOrUpdateMembersAccess) {
|
||||
throw new AuthenticationError("Not authorized");
|
||||
}
|
||||
|
||||
const invite = await inviteUser({
|
||||
organizationId: organizations[0].id,
|
||||
invitee: {
|
||||
email,
|
||||
name: "",
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
|
||||
if (invite) {
|
||||
await sendInviteMemberEmail(
|
||||
invite.id,
|
||||
email,
|
||||
session.user.name ?? "",
|
||||
"",
|
||||
false // is onboarding invite
|
||||
);
|
||||
}
|
||||
|
||||
return invite;
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { inviteOrganizationMemberAction } from "@/app/setup/organization/[organizationId]/invite/actions";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { TInviteMembersFormSchema, ZInviteMembersFormSchema } from "@formbricks/types/invites";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormError, FormField, FormItem, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
interface InviteMembersProps {
|
||||
IS_SMTP_CONFIGURED: boolean;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export const InviteMembers = ({ IS_SMTP_CONFIGURED, organizationId }: InviteMembersProps) => {
|
||||
const [membersCount, setMembersCount] = useState(1);
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TInviteMembersFormSchema>({
|
||||
resolver: zodResolver(ZInviteMembersFormSchema),
|
||||
});
|
||||
|
||||
const { isSubmitting } = form.formState;
|
||||
|
||||
const inviteTeamMembers = async (data: TInviteMembersFormSchema) => {
|
||||
const emails = Object.values(data).filter((email) => email && email.trim());
|
||||
if (!emails.length) {
|
||||
router.push("/onboarding");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const email of emails) {
|
||||
try {
|
||||
if (!email) continue;
|
||||
await inviteOrganizationMemberAction(email, organizationId);
|
||||
if (IS_SMTP_CONFIGURED) {
|
||||
toast.success(`Invitation sent to ${email}!`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to invite:", email, error);
|
||||
toast.error(`Failed to invite ${email}.`);
|
||||
}
|
||||
}
|
||||
|
||||
router.push("/onboarding");
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
router.push("/onboarding");
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{!IS_SMTP_CONFIGURED && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>SMTP not configured</AlertTitle>
|
||||
<AlertDescription>
|
||||
Invitations cannot be sent at this time because the email service is not configured. You can copy
|
||||
the invite link in the organization settings later.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<form onSubmit={form.handleSubmit(inviteTeamMembers)} className="space-y-4">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h2 className="text-2xl font-medium">Invite your Organization members</h2>
|
||||
<p>Life's no fun alone.</p>
|
||||
|
||||
{Array.from({ length: membersCount }).map((_, index) => (
|
||||
<FormField
|
||||
key={`member-${index}`}
|
||||
control={form.control}
|
||||
name={`member-${index}`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={`user@example.com`}
|
||||
className="w-80"
|
||||
isInvalid={!!error?.message}
|
||||
/>
|
||||
</div>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="minimal"
|
||||
onClick={() => setMembersCount((count) => count + 1)}
|
||||
type="button"
|
||||
StartIcon={PlusIcon}>
|
||||
Add another member
|
||||
</Button>
|
||||
|
||||
<hr className="my-6 w-full border-slate-200" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="flex w-80 justify-center"
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
<Button type="button" variant="minimal" className="flex w-80 justify-center" onClick={handleSkip}>
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { InviteMembers } from "@/app/setup/organization/[organizationId]/invite/components/InviteMembers";
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_USER } from "@formbricks/lib/constants";
|
||||
import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Invite",
|
||||
description: "Open-source Experience Management. Free & open source.",
|
||||
};
|
||||
|
||||
const Page = async ({ params }) => {
|
||||
const IS_SMTP_CONFIGURED: boolean = SMTP_HOST && SMTP_PORT && SMTP_USER && SMTP_PASSWORD ? true : false;
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthenticationError("Not Authenticated");
|
||||
|
||||
const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(
|
||||
params.organizationId,
|
||||
session.user.id
|
||||
);
|
||||
|
||||
if (!hasCreateOrUpdateMembersAccess || session.user.onboardingCompleted) return notFound();
|
||||
|
||||
return <InviteMembers IS_SMTP_CONFIGURED={IS_SMTP_CONFIGURED} organizationId={params.organizationId} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,52 @@
|
||||
"use server";
|
||||
|
||||
import { Organization } from "@prisma/client";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { gethasNoOrganizations } from "@formbricks/lib/instance/service";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
import { createOrganization } from "@formbricks/lib/organization/service";
|
||||
import { createProduct } from "@formbricks/lib/product/service";
|
||||
import { updateUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
|
||||
export const createOrganizationAction = async (organizationName: string): Promise<Organization> => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const hasNoOrganizations = await gethasNoOrganizations();
|
||||
if (!hasNoOrganizations) {
|
||||
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
|
||||
}
|
||||
|
||||
const newOrganization = await createOrganization({
|
||||
name: organizationName,
|
||||
});
|
||||
|
||||
await createMembership(newOrganization.id, session.user.id, {
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const product = await createProduct(newOrganization.id, {
|
||||
name: "My Product",
|
||||
});
|
||||
|
||||
const updatedNotificationSettings = {
|
||||
...session.user.notificationSettings,
|
||||
alert: {
|
||||
...session.user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...session.user.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(session.user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
return newOrganization;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { createOrganizationAction } from "@/app/setup/organization/create/actions";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ZOrganization } from "@formbricks/types/organizations";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormError, FormField, FormItem, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
const ZCreateFirstOrganizationFormSchema = ZOrganization.pick({ name: true });
|
||||
type TCreateFirstOrganizationForm = z.infer<typeof ZCreateFirstOrganizationFormSchema>;
|
||||
|
||||
export const CreateFirstOrganization = () => {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const form = useForm<TCreateFirstOrganizationForm>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZCreateFirstOrganizationFormSchema),
|
||||
});
|
||||
|
||||
const organizationName = form.watch("name");
|
||||
|
||||
const onSubmit: SubmitHandler<TCreateFirstOrganizationForm> = async (data) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const organizationName = data.name.trim();
|
||||
const organization = await createOrganizationAction(organizationName);
|
||||
router.push(`/setup/organization/${organization.id}/invite`);
|
||||
} catch (error) {
|
||||
toast.error("Some error occurred while creating organization");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h2 className="text-2xl font-medium">Setup your organization</h2>
|
||||
<p>Make it yours.</p>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
placeholder="e.g., Acme Inc"
|
||||
className="w-80"
|
||||
required
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="flex w-80 justify-center"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || organizationName.trim() === ""}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { formbricksLogout } from "@/app/lib/formbricks";
|
||||
import { Session } from "next-auth";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { DeleteAccountModal } from "@formbricks/ui/DeleteAccountModal";
|
||||
|
||||
interface RemovedFromOrganizationProps {
|
||||
session: Session;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
}
|
||||
|
||||
export const RemovedFromOrganization = ({ session, IS_FORMBRICKS_CLOUD }: RemovedFromOrganizationProps) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>No membership found!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Unfortunately, you have been removed from the organization. If you believe this was a mistake,
|
||||
please reach out to the organization owner.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<hr className="my-4 border-slate-200" />
|
||||
<p className="text-sm">
|
||||
If you want to delete your account, you can do so by clicking the button below.
|
||||
</p>
|
||||
<DeleteAccountModal
|
||||
open={isModalOpen}
|
||||
setOpen={setIsModalOpen}
|
||||
session={session}
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
formbricksLogout={formbricksLogout}
|
||||
/>
|
||||
<Button variant="darkCTA" onClick={() => setIsModalOpen(true)}>
|
||||
Delete account
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { RemovedFromOrganization } from "@/app/setup/organization/create/components/RemovedFromOrganization";
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { gethasNoOrganizations } from "@formbricks/lib/instance/service";
|
||||
import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
|
||||
import { CreateFirstOrganization } from "./components/CreateFirstOrganiztion";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Organization",
|
||||
description: "Open-source Experience Management. Free & open source.",
|
||||
};
|
||||
|
||||
const Page = async () => {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) throw new AuthenticationError("Not Authenticated");
|
||||
|
||||
const hasNoOrganizations = await gethasNoOrganizations();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const userOrganizations = await getOrganizationsByUserId(session.user.id);
|
||||
|
||||
if (!hasNoOrganizations && userOrganizations.length === 0 && !isMultiOrgEnabled) {
|
||||
return <RemovedFromOrganization session={session} IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD} />;
|
||||
}
|
||||
|
||||
if (userOrganizations.length !== 0 || (!hasNoOrganizations && !isMultiOrgEnabled)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <CreateFirstOrganization />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
Reference in New Issue
Block a user