diff --git a/apps/web/pages/api/auth/setup.ts b/apps/web/pages/api/auth/setup.ts new file mode 100644 index 0000000000..e87953ec0a --- /dev/null +++ b/apps/web/pages/api/auth/setup.ts @@ -0,0 +1,58 @@ +import { IdentityProvider } from "@prisma/client"; +import { NextApiRequest, NextApiResponse } from "next"; +import z from "zod"; + +import { isPasswordValid } from "@calcom/lib/auth"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import { hashPassword } from "@lib/auth"; +import prisma from "@lib/prisma"; +import slugify from "@lib/slugify"; + +const querySchema = z.object({ + username: z.string().min(1), + fullname: z.string(), + email: z.string().email({ message: "Please enter a valid email" }), + password: z.string().refine((val) => isPasswordValid(val.trim()), { + message: + "The password must be a minimum of 7 characters long containing at least one number and have a mixture of uppercase and lowercase letters", + }), +}); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const userCount = await prisma.user.count(); + if (userCount !== 0) { + throw new HttpError({ statusCode: 400, message: "No setup needed." }); + } + + const parsedQuery = querySchema.safeParse(req.body); + if (!parsedQuery.success) { + throw new HttpError({ statusCode: 422, message: parsedQuery.error.message }); + } + + const username = slugify(parsedQuery.data.username); + const userEmail = parsedQuery.data.email.toLowerCase(); + + const hashedPassword = await hashPassword(parsedQuery.data.password); + + await prisma.user.create({ + data: { + username, + email: userEmail, + password: hashedPassword, + role: "ADMIN", + name: parsedQuery.data.fullname, + emailVerified: new Date(), + locale: "en", // TODO: We should revisit this + plan: "PRO", + identityProvider: IdentityProvider.CAL, + }, + }); + + res.status(201).json({ message: "First admin user created successfuly." }); +} + +export default defaultHandler({ + POST: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index 1558bbbaa3..54c4132eaf 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -8,13 +8,14 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Alert } from "@calcom/ui/Alert"; import Button from "@calcom/ui/Button"; import { EmailField, Form, PasswordField } from "@calcom/ui/form/fields"; +import prisma from "@calcom/web/lib/prisma"; import { ErrorCode, getSession } from "@lib/auth"; import { WEBAPP_URL, WEBSITE_URL } from "@lib/config/constants"; -import { useLocale } from "@lib/hooks/useLocale"; import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@lib/saml"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -217,6 +218,17 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }; } + const userCount = await prisma.user.count(); + if (userCount === 0) { + // Proceed to new onboarding to create first admin user + return { + redirect: { + destination: "/auth/setup", + permanent: false, + }, + }; + } + return { props: { csrfToken: await getCsrfToken(context), diff --git a/apps/web/pages/auth/setup.tsx b/apps/web/pages/auth/setup.tsx new file mode 100644 index 0000000000..c11def4ba6 --- /dev/null +++ b/apps/web/pages/auth/setup.tsx @@ -0,0 +1,293 @@ +import { CheckIcon } from "@heroicons/react/solid"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/router"; +import { Controller, useForm } from "react-hook-form"; +import * as z from "zod"; + +import { isPasswordValid } from "@calcom/lib/auth"; +import classNames from "@calcom/lib/classNames"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { inferSSRProps } from "@calcom/types/inferSSRProps"; +import WizardForm from "@calcom/ui/WizardForm"; +import { Input } from "@calcom/ui/form/fields"; +import { Form } from "@calcom/ui/form/fields"; + +import prisma from "@lib/prisma"; + +const schema = z.object({ + username: z.string().min(1), + email: z.string().email({ message: "Please enter a valid email" }), + fullname: z.string(), + password: z.string().refine((val) => isPasswordValid(val.trim()), { + message: + "The password must be a minimum of 7 characters long containing at least one number and have a mixture of uppercase and lowercase letters", + }), +}); + +const StepDone = () => { + const { t } = useLocale(); + + return ( +
+
+ +
+
+

{t("all_done")}

+
+
+ ); +}; + +const SetupFormStep1 = () => { + const router = useRouter(); + const { t } = useLocale(); + + const formMethods = useForm<{ + username: string; + email: string; + fullname: string; + password: string; + }>({ + resolver: zodResolver(schema), + }); + + return ( +
{ + const response = await fetch("/api/auth/setup", { + method: "POST", + body: JSON.stringify({ + username: data.username, + fullname: data.fullname, + email: data.email.toLowerCase(), + password: data.password, + }), + headers: { + "Content-Type": "application/json", + }, + }); + if (response.status === 201) { + router.replace("/auth/login"); + } else { + router.replace(`/auth/setup`); + } + }}> +
+ +
+ + cal.com/ + + + ( + { + onChange(e.target.value); + formMethods.setValue("username", e.target.value); + await formMethods.trigger("username"); + }} + defaultValue={router.query.email} + color={formMethods.formState.errors.username ? "warn" : ""} + type="text" + name="username" + autoComplete="off" + autoCorrect="off" + autoCapitalize="none" + placeholder={t("username")} + className="rounded-r-s mt-0 block min-w-0 flex-1 rounded-none border-gray-300 px-3 py-2 sm:text-sm" + /> + )} + /> +
+ {formMethods.formState.errors.username && ( +

+ {formMethods.formState.errors.username.message} +

+ )} +
+
+ +
+ ( + { + onChange(e.target.value); + formMethods.setValue("fullname", e.target.value); + await formMethods.trigger("fullname"); + }} + defaultValue={router.query.fullname} + color={formMethods.formState.errors.fullname ? "warn" : ""} + type="text" + name="fullname" + autoCapitalize="none" + autoComplete="name" + autoCorrect="off" + placeholder={t("full_name")} + className={classNames( + "rounded-r-s mt-0 block min-w-0 flex-1 rounded-none border-gray-300 px-3 py-2 sm:text-sm", + formMethods.formState.errors.fullname + ? "border-r-0 focus:border-l focus:border-gray-300 focus:ring-0" + : "focus:border-gray-900 focus:ring-gray-900" + )} + /> + )} + /> +
+ {formMethods.formState.errors.fullname && ( +

+ {formMethods.formState.errors.fullname.message} +

+ )} +
+
+ +
+ ( + { + onChange(e.target.value); + formMethods.setValue("email", e.target.value); + await formMethods.trigger("email"); + }} + defaultValue={router.query.email} + color={formMethods.formState.errors.email ? "warn" : ""} + type="email" + name="email" + autoCapitalize="none" + autoComplete="email" + autoCorrect="off" + placeholder={t("email_address")} + className={classNames( + "rounded-r-s mt-0 block min-w-0 flex-1 rounded-none border-gray-300 px-3 py-2 sm:text-sm", + formMethods.formState.errors.email + ? "border-r-0 focus:border-l focus:border-gray-300 focus:ring-0" + : "focus:border-gray-900 focus:ring-gray-900" + )} + /> + )} + /> +
+ {formMethods.formState.errors.email && ( +

+ {formMethods.formState.errors.email.message} +

+ )} +
+
+ +
+ ( + { + onChange(e.target.value); + formMethods.setValue("password", e.target.value); + await formMethods.trigger("password"); + }} + color={formMethods.formState.errors.password ? "warn" : ""} + type="password" + name="password" + autoComplete="off" + placeholder={t("password")} + className={classNames( + "rounded-r-s mt-0 block min-w-0 flex-1 rounded-none border-gray-300 px-3 py-2 sm:text-sm", + formMethods.formState.errors.password + ? "border-r-0 focus:border-l focus:border-gray-300 focus:ring-0" + : "focus:border-gray-900 focus:ring-gray-900" + )} + /> + )} + /> +
+ {formMethods.formState.errors.password && ( +

+ {formMethods.formState.errors.password.message} +

+ )} +
+ + +
+ ); +}; + +export default function Setup(props: inferSSRProps) { + const { t } = useLocale(); + + const steps = [ + { + title: t("administrator_user"), + description: t("lets_create_first_administrator_user"), + content: props.userCount !== 0 ? : , + enabled: props.userCount === 0, // to check if the wizard should show buttons to navigate through more steps + }, + ]; + + return ( + <> +
+ +
+ + ); +} + +export const getServerSideProps = async () => { + const userCount = await prisma.user.count(); + return { + props: { + userCount, + }, + }; +}; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 62881b9158..3950eb5722 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -213,6 +213,7 @@ "forgot_password": "Forgot Password", "forgot": "Forgot?", "done": "Done", + "all_done": "All done!", "check_email_reset_password": "Check your email. We sent you a link to reset your password.", "finish": "Finish", "few_sentences_about_yourself": "A few sentences about yourself. This will appear on your personal url page.", @@ -477,6 +478,8 @@ "member": "Member", "owner": "Owner", "admin": "Admin", + "administrator_user": "Administrator user", + "lets_create_first_administrator_user": "Let's create the first administrator user.", "new_member": "New Member", "invite": "Invite", "invite_new_member": "Invite a new member", diff --git a/packages/lib/auth.ts b/packages/lib/auth.ts index 976b6c32ce..893e659da2 100644 --- a/packages/lib/auth.ts +++ b/packages/lib/auth.ts @@ -9,3 +9,19 @@ export async function verifyPassword(password: string, hashedPassword: string) { const isValid = await compare(password, hashedPassword); return isValid; } + +export function isPasswordValid(password: string) { + let cap = false, + low = false, + num = false, + min = false; + if (password.length > 6) min = true; + for (let i = 0; i < password.length; i++) { + if (!isNaN(parseInt(password[i]))) num = true; + else { + if (password[i] === password[i].toUpperCase()) cap = true; + if (password[i] === password[i].toLowerCase()) low = true; + } + } + return cap && low && num && min; +} diff --git a/packages/ui/Stepper.tsx b/packages/ui/Stepper.tsx new file mode 100644 index 0000000000..4e51d7752d --- /dev/null +++ b/packages/ui/Stepper.tsx @@ -0,0 +1,50 @@ +import Link from "next/link"; + +type DefaultStep = { + title: string; +}; + +function Stepper(props: { href: string; step: number; steps: T[] }) { + const { href, steps } = props; + return ( + <> + {steps.length > 1 && ( + + )} + + ); +} + +export default Stepper; diff --git a/packages/ui/WizardForm.tsx b/packages/ui/WizardForm.tsx new file mode 100644 index 0000000000..47dbbce7ce --- /dev/null +++ b/packages/ui/WizardForm.tsx @@ -0,0 +1,59 @@ +import { useRouter } from "next/router"; + +import Stepper from "./Stepper"; + +type DefaultStep = { + title: string; + description: string; + content: JSX.Element; + enabled?: boolean; +}; + +function WizardForm(props: { href: string; steps: T[] }) { + const { href, steps } = props; + const router = useRouter(); + const step = parseInt((router.query.step as string) || "1"); + const currentStep = steps[step - 1]; + const setStep = (newStep: number) => { + router.replace(`${href}?step=${newStep || 1}`, undefined, { shallow: true }); + }; + + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Cal.com Logo +
+
+

{currentStep.title}

+

{currentStep.description}

+
+
{currentStep.content}
+ {currentStep.enabled !== false && ( +
+ {step > 1 && ( + + )} + + +
+ )} +
+
+ +
+
+ ); +} + +export default WizardForm;