diff --git a/apps/web/pages/api/auth/setup.ts b/apps/web/pages/api/auth/setup.ts new file mode 100644 index 0000000000..353dfe082a --- /dev/null +++ b/apps/web/pages/api/auth/setup.ts @@ -0,0 +1,57 @@ +import { IdentityProvider } from "@prisma/client"; +import { NextApiRequest, NextApiResponse } from "next"; +import z from "zod"; + +import { isPasswordValid } from "@calcom/lib/auth"; +import { hashPassword } from "@calcom/lib/auth"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import slugify from "@calcom/lib/slugify"; +import prisma from "@calcom/prisma"; + +const querySchema = z.object({ + username: z.string().min(1), + full_name: z.string(), + email_address: 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_address.toLowerCase(); + + const hashedPassword = await hashPassword(parsedQuery.data.password); + + await prisma.user.create({ + data: { + username, + email: userEmail, + password: hashedPassword, + role: "ADMIN", + name: parsedQuery.data.full_name, + emailVerified: new Date(), + locale: "en", // TODO: We should revisit this + plan: "PRO", + identityProvider: IdentityProvider.CAL, + }, + }); + + return { 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 7ba76d3353..2d8716cbb4 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -7,14 +7,15 @@ 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 { Icon } from "@calcom/ui/Icon"; 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"; @@ -136,6 +137,7 @@ export default function Login({ { + const { t } = useLocale(); + + return ( +
+
+ +
+
+

{t("all_done")}

+
+
+ ); +}; + +const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => { + const router = useRouter(); + const { t } = useLocale(); + + const formSchema = z.object({ + username: z.string().min(1, t("at_least_characters", { count: 1 })), + email_address: z.string().email({ message: t("enter_valid_email") }), + full_name: z.string().min(3, t("at_least_characters", { count: 3 })), + password: z.string().refine((val) => isPasswordValid(val.trim()), { + message: t("invalid_password_hint"), + }), + }); + + const formMethods = useForm<{ + username: string; + email_address: string; + full_name: string; + password: string; + }>({ + resolver: zodResolver(formSchema), + }); + + const formRef = useRef(null); + + const onError = () => { + props.setIsLoading(false); + }; + + const onSubmit = formMethods.handleSubmit(async (data: z.infer) => { + props.setIsLoading(true); + const response = await fetch("/api/auth/setup", { + method: "POST", + body: JSON.stringify({ + username: data.username, + full_name: data.full_name, + email_address: data.email_address.toLowerCase(), + password: data.password, + }), + headers: { + "Content-Type": "application/json", + }, + }); + if (response.status === 200) { + router.replace(`/auth/login?email=${data.email_address.toLowerCase()}`); + } else { + router.replace("/auth/setup"); + } + }, onError); + + return ( + +
+
+ ( + + {process.env.NEXT_PUBLIC_WEBSITE_URL}/ + + } + value={value || ""} + className="my-0" + onBlur={onBlur} + name="username" + onChange={async (e) => { + onChange(e.target.value); + formMethods.setValue("username", e.target.value); + await formMethods.trigger("username"); + }} + /> + )} + /> +
+
+ ( + { + onChange(e.target.value); + formMethods.setValue("full_name", e.target.value); + await formMethods.trigger("full_name"); + }} + color={formMethods.formState.errors.full_name ? "warn" : ""} + type="text" + name="full_name" + autoCapitalize="none" + autoComplete="name" + autoCorrect="off" + className="my-0" + /> + )} + /> +
+
+ ( + { + onChange(e.target.value); + formMethods.setValue("email_address", e.target.value); + await formMethods.trigger("email_address"); + }} + defaultValue={router.query.email_address} + className="my-0" + name="email_address" + /> + )} + /> +
+
+ ( + { + onChange(e.target.value); + formMethods.setValue("password", e.target.value); + await formMethods.trigger("password"); + }} + name="password" + className="my-0" + autoComplete="off" + /> + )} + /> +
+
+
+ ); +}; + +export default function Setup(props: inferSSRProps) { + const { t } = useLocale(); + const [isLoadingStep1, setIsLoadingStep1] = useState(false); + + 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 + isLoading: isLoadingStep1, + }, + ]; + + 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 d71ef7b195..ca47deec69 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.", @@ -404,6 +405,7 @@ "new_password_matches_old_password": "New password matches your old password. Please choose a different password.", "forgotten_secret_description": "If you have lost or forgotten this secret, you can change it, but be aware that all integrations using this secret will need to be updated", "current_incorrect_password": "Current password is incorrect", + "invalid_password_hint": "The password must be a minimum of 7 characters long containing at least one number and have a mixture of uppercase and lowercase letters", "incorrect_password": "Password is incorrect.", "1_on_1": "1-on-1", "24_h": "24h", @@ -434,6 +436,7 @@ "additional_guests": "+ Additional Guests", "your_name": "Your name", "email_address": "Email address", + "valid_email_address": "Please enter a valid email", "location": "Location", "yes": "yes", "no": "no", @@ -477,6 +480,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", @@ -614,6 +619,8 @@ "change_weekly_schedule": "Change your weekly schedule", "logo": "Logo", "error": "Error", + "at_least_characters_one": "Please enter at least one character", + "at_least_characters_other": "Please enter at least {{count}} characters", "team_logo": "Team Logo", "add_location": "Add a location", "attendees": "Attendees", diff --git a/packages/config/tailwind-preset.js b/packages/config/tailwind-preset.js index bd314f4d7b..aad5320c2e 100644 --- a/packages/config/tailwind-preset.js +++ b/packages/config/tailwind-preset.js @@ -16,7 +16,22 @@ module.exports = { extend: { colors: { /* your primary brand color */ - brand: "var(--brand-color)", + brand: { + // TODO: To be shared between Storybook for v2 UI components and web + // Figure out a way to automate this for self hosted users + // Goto https://javisperez.github.io/tailwindcolorshades to generate your brand color + 50: "#d1d5db", + 100: "#9ca3af", + 200: "#6b7280", + 300: "#4b5563", + 400: "#374151", + 500: "#111827", // Brand color + 600: "#0f1623", + 700: "#0d121d", + 800: "#0a0e17", + 900: "#080c13", + DEFAULT: "#111827", + }, brandcontrast: "var(--brand-text-color)", darkmodebrand: "var(--brand-color-dark-mode)", darkmodebrandcontrast: "var(--brand-text-color-dark-mode)", diff --git a/packages/lib/auth.ts b/packages/lib/auth.ts index 852592bc11..f72271c835 100644 --- a/packages/lib/auth.ts +++ b/packages/lib/auth.ts @@ -18,3 +18,19 @@ export async function getSession(options: GetSessionParams): Promise 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/v2/Stepper.tsx b/packages/ui/v2/Stepper.tsx new file mode 100644 index 0000000000..4e51d7752d --- /dev/null +++ b/packages/ui/v2/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/v2/WizardForm.tsx b/packages/ui/v2/WizardForm.tsx new file mode 100644 index 0000000000..5576398973 --- /dev/null +++ b/packages/ui/v2/WizardForm.tsx @@ -0,0 +1,69 @@ +import { useRouter } from "next/router"; + +import classNames from "@calcom/lib/classNames"; +import Button from "@calcom/ui/v2/Button"; +import Stepper from "@calcom/ui/v2/Stepper"; + +type DefaultStep = { + title: string; + description: string; + content: JSX.Element; + enabled?: boolean; + isLoading: boolean; +}; + +function WizardForm(props: { href: string; steps: T[]; containerClassname?: string }) { + 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; diff --git a/packages/ui/v2/form/fields.tsx b/packages/ui/v2/form/fields.tsx index dfd7b789d3..d87ce225c3 100644 --- a/packages/ui/v2/form/fields.tsx +++ b/packages/ui/v2/form/fields.tsx @@ -81,14 +81,14 @@ const InputField = forwardRef(function InputF )} {addOnLeading || addOnSuffix ? (