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:
Dhruwang Jariwala
2024-06-10 23:26:02 +05:30
committed by GitHub
parent 9888d128a2
commit 5bf5825f30
118 changed files with 1384 additions and 712 deletions
@@ -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;
+23
View File
@@ -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&apos;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;