From c1896fc8335300625bbdf9e35cd62d35dc839d01 Mon Sep 17 00:00:00 2001 From: Agusti Fernandez Pardo <6601142+agustif@users.noreply.github.com> Date: Mon, 1 Aug 2022 23:44:08 +0200 Subject: [PATCH] Feat/onboarding username (#3580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove duplicate button in textusernamefield * feat: adds username if not present in getting-started first onboarding step * fix: credentials * fix: typo * fix: user type * fix: type errors * Validates successRedirectUrl earlier with zod * Removes deprecated api endpoint * Reduces the data needed for the frontend on onboarding Co-authored-by: Agusti Fernandez Pardo Co-authored-by: Peer Richelsen Co-authored-by: Omar López --- .../UsernameAvailability/PremiumTextfield.tsx | 2 +- .../UsernameTextfield.tsx | 2 +- .../queries/event-types/get-event-types.ts | 11 - apps/web/pages/api/event-type/index.ts | 47 ----- apps/web/pages/getting-started.tsx | 199 +++++++++--------- packages/prisma/schema.prisma | 1 + packages/prisma/zod-utils.ts | 20 ++ .../trpc/server/routers/viewer/eventTypes.tsx | 30 ++- 8 files changed, 135 insertions(+), 177 deletions(-) delete mode 100644 apps/web/lib/queries/event-types/get-event-types.ts delete mode 100644 apps/web/pages/api/event-type/index.ts diff --git a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx index 6ec82c4f34..a46b0502df 100644 --- a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx @@ -245,7 +245,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => { )} {(usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue && ( -
+
)} diff --git a/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx index 14a70abe69..0221287e71 100644 --- a/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx @@ -149,7 +149,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => { {markAsError &&

Username is already taken

} {usernameIsAvailable && currentUsername !== inputUsernameValue && ( -
+
)} diff --git a/apps/web/lib/queries/event-types/get-event-types.ts b/apps/web/lib/queries/event-types/get-event-types.ts deleted file mode 100644 index 97b79b6192..0000000000 --- a/apps/web/lib/queries/event-types/get-event-types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { EventType } from "@prisma/client"; - -import * as fetch from "@lib/core/http/fetch-wrapper"; - -type GetEventsResponse = { message: string; data: EventType[] }; -const getEventTypes = async () => { - const response = await fetch.get("/api/event-type"); - return response.data; -}; - -export default getEventTypes; diff --git a/apps/web/pages/api/event-type/index.ts b/apps/web/pages/api/event-type/index.ts deleted file mode 100644 index ea3bd0fe81..0000000000 --- a/apps/web/pages/api/event-type/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import prisma from "@calcom/prisma"; - -import { getSession } from "@lib/auth"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const session = await getSession({ req: req }); - - if (!session) { - res.status(401).json({ message: "Not authenticated" }); - return; - } - - if (!session.user?.id) { - console.error("Session is missing a user id"); - return res.status(500).json({ message: "Something went wrong" }); - } - - if (req.method === "GET") { - const user = await prisma.user.findUnique({ - where: { - id: session.user.id, - }, - select: { - id: true, - eventTypes: { - where: { - team: null, - }, - select: { - id: true, - title: true, - description: true, - length: true, - schedulingType: true, - slug: true, - hidden: true, - metadata: true, - }, - }, - }, - }); - - return res.status(200).json({ message: "Events.", data: user?.eventTypes }); - } -} diff --git a/apps/web/pages/getting-started.tsx b/apps/web/pages/getting-started.tsx index 82773f7c03..68b9864ee7 100644 --- a/apps/web/pages/getting-started.tsx +++ b/apps/web/pages/getting-started.tsx @@ -12,14 +12,16 @@ import { useForm } from "react-hook-form"; import * as z from "zod"; import getApps from "@calcom/app-store/utils"; -import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import dayjs from "@calcom/dayjs"; +import { DEFAULT_SCHEDULE } from "@calcom/lib/availability"; import { DOCS_URL } from "@calcom/lib/constants"; import { fetchUsername } from "@calcom/lib/fetchUsername"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import showToast from "@calcom/lib/notification"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; -import { isBrowserLocale24h } from "@calcom/lib/timeFormat"; import prisma from "@calcom/prisma"; import { trpc } from "@calcom/trpc/react"; +import type { AppRouter } from "@calcom/trpc/server/routers/_app"; import { Alert } from "@calcom/ui/Alert"; import Button from "@calcom/ui/Button"; import { Icon } from "@calcom/ui/Icon"; @@ -27,8 +29,6 @@ import TimezoneSelect from "@calcom/ui/form/TimezoneSelect"; import { Form } from "@calcom/ui/form/fields"; import { getSession } from "@lib/auth"; -import { DEFAULT_SCHEDULE } from "@lib/availability"; -import { useLocale } from "@lib/hooks/useLocale"; import { inferSSRProps } from "@lib/types/inferSSRProps"; import { Schedule as ScheduleType } from "@lib/types/schedule"; @@ -36,8 +36,9 @@ import { ClientSuspense } from "@components/ClientSuspense"; import Loader from "@components/Loader"; import Schedule from "@components/availability/Schedule"; import { CalendarListContainer } from "@components/integrations/CalendarListContainer"; +import { UsernameAvailability } from "@components/ui/UsernameAvailability"; -import getEventTypes from "../lib/queries/event-types/get-event-types"; +import { TRPCClientErrorLike } from "@trpc/client"; type ScheduleFormValues = { schedule: ScheduleType; @@ -47,20 +48,36 @@ let mutationComplete: ((err: Error | null) => void) | null; export default function Onboarding(props: inferSSRProps) { const { t } = useLocale(); + const { user } = props; const router = useRouter(); + const utils = trpc.useContext(); const telemetry = useTelemetry(); + const [hasErrors, setHasErrors] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const { data: eventTypes } = trpc.useQuery(["viewer.eventTypes.list"]); + const onSuccessMutation = async () => { + showToast(t("your_user_profile_updated_successfully"), "success"); + setHasErrors(false); // dismiss any open errors + await utils.invalidateQueries(["viewer.me"]); + }; + const onErrorMutation = (error: TRPCClientErrorLike) => { + setHasErrors(true); + setErrorMessage(error.message); + document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" }); + }; const mutation = trpc.useMutation("viewer.updateProfile", { onSuccess: async () => { setSubmitting(true); setEnteredName(nameRef.current?.value || ""); + setInputUsernameValue(usernameRef.current?.value || ""); if (mutationComplete) { mutationComplete(null); mutationComplete = null; } setSubmitting(false); }, - onError: (err) => { + onError: (err: TRPCClientErrorLike) => { setError(new Error(err.message)); if (mutationComplete) { mutationComplete(new Error(err.message)); @@ -90,6 +107,9 @@ export default function Onboarding(props: inferSSRProps { - const res = await fetch(`/api/user/${props.user.id}`, { + const res = await fetch(`/api/user/${user.id}`, { method: "PATCH", body: JSON.stringify({ data: { ...data } }), headers: { @@ -112,44 +132,29 @@ export default function Onboarding(props: inferSSRProps { - throw new Error(err.message); + onError: (error: TRPCClientErrorLike) => { + throw new Error(error.message); }, }); /** Name */ const nameRef = useRef(null); + /** Username */ + const usernameRef = useRef(null!); const bioRef = useRef(null); /** End Name */ /** TimeZone */ - const [selectedTimeZone, setSelectedTimeZone] = useState(props.user.timeZone ?? dayjs.tz.guess()); + const [selectedTimeZone, setSelectedTimeZone] = useState(user.timeZone ?? dayjs.tz.guess()); /** End TimeZone */ /** Onboarding Steps */ - const [currentStep, setCurrentStep] = useState(0); - const detectStep = () => { - // Always set timezone if new user - let step = 0; - - const hasConfigureCalendar = props.integrations.some((integration) => integration.credential !== null); - if (hasConfigureCalendar) { - step = 2; - } - - const hasSchedules = props.schedules && props.schedules.length > 0; - if (hasSchedules) { - step = 3; - } - - setCurrentStep(step); - }; - + const [currentStep, setCurrentStep] = useState(props.initialStep); const handleConfirmStep = async () => { try { setSubmitting(true); @@ -205,22 +210,15 @@ export default function Onboarding(props: inferSSRProps { setSubmitting(true); - if (!props.eventTypes || props.eventTypes.length === 0) { - const eventTypes = await getEventTypes(); - if (eventTypes.length === 0) { - await Promise.all( - DEFAULT_EVENT_TYPES.map(async (event) => { - return createEventType.mutate(event); - }) - ); - } + if (eventTypes?.length === 0) { + await Promise.all( + DEFAULT_EVENT_TYPES.map(async (event) => { + return createEventType.mutate(event); + }) + ); } - // Write default timeformat to localStorage - const browserTimeFormat = isBrowserLocale24h() ? 24 : 12; - await updateUser({ completedOnboarding: true, - timeFormat: browserTimeFormat, }); setSubmitting(false); @@ -301,7 +299,7 @@ export default function Onboarding(props: inferSSRProps + {hasErrors && } + { formMethods.setValue("token", e.target.value); @@ -323,7 +323,7 @@ export default function Onboarding(props: inferSSRProps @@ -641,12 +654,41 @@ export async function getServerSideProps(context: NextPageContext) { timeZone: true, identityProvider: true, completedOnboarding: true, + weekStart: true, + hideBranding: true, + theme: true, + plan: true, + brandColor: true, + darkBrandColor: true, + metadata: true, + timeFormat: true, + allowDynamicBooking: true, selectedCalendars: { select: { externalId: true, integration: true, }, }, + credentials: { + where: { + userId: session.user.id, + }, + select: { + id: true, + type: true, + key: true, + userId: true, + appId: true, + }, + }, + schedules: { + where: { + userId: session.user.id, + }, + select: { + id: true, + }, + }, }, }); if (!user) { @@ -662,59 +704,18 @@ export async function getServerSideProps(context: NextPageContext) { }; } - const credentials = await prisma.credential.findMany({ - where: { - userId: user.id, - }, - select: { - id: true, - type: true, - key: true, - userId: true, - appId: true, - }, - }); - - const integrations = getApps(credentials) + const integrations = getApps(user.credentials) .filter((item) => item.type.endsWith("_calendar")) .map((item) => omit(item, "key")); - - // get user's credentials + their connected integrations - const calendarCredentials = getCalendarCredentials(credentials, user.id); - // get all the connected integrations' calendars (from third party) - const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); - - const eventTypes = await prisma.eventType.findMany({ - where: { - userId: user.id, - }, - select: { - id: true, - title: true, - slug: true, - description: true, - length: true, - hidden: true, - }, - }); - - const schedules = await prisma.schedule.findMany({ - where: { - userId: user.id, - }, - select: { - id: true, - }, - }); + const { schedules } = user; + const hasConfigureCalendar = integrations.some((integration) => integration.credential !== null); + const hasSchedules = schedules && schedules.length > 0; return { props: { session, user, - integrations, - connectedCalendars, - eventTypes, - schedules, + initialStep: hasSchedules ? (hasConfigureCalendar ? 2 : 3) : 0, }, }; } diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 752857ef33..5ebd5b72ee 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -75,6 +75,7 @@ model EventType { currency String @default("usd") slotInterval Int? metadata Json? + /// @zod.custom(imports.successRedirectUrl) successRedirectUrl String? workflows WorkflowsOnEventTypes[] diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index b97800d353..edfe7f6142 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -111,3 +111,23 @@ export const userMetadata = z intentUsername: z.string().optional(), }) .nullable(); + +/** + * Ensures that it is a valid HTTP URL + * It automatically avoids + * - XSS attempts through javascript:alert('hi') + * - mailto: links + */ +export function assertValidUrl(url: string | null | undefined) { + if (!url) return false; + return url.startsWith("http://") && url.startsWith("https://"); +} + +export const successRedirectUrl = z + .string() + .url() + .refine(assertValidUrl, { + path: ["successRedirectUrl"], + message: "Invalid URL", + }) + .nullish(); diff --git a/packages/trpc/server/routers/viewer/eventTypes.tsx b/packages/trpc/server/routers/viewer/eventTypes.tsx index 7c920b34c4..bca1ae3dba 100644 --- a/packages/trpc/server/routers/viewer/eventTypes.tsx +++ b/packages/trpc/server/routers/viewer/eventTypes.tsx @@ -17,22 +17,6 @@ function isPeriodType(keyInput: string): keyInput is PeriodType { return Object.keys(PeriodType).includes(keyInput); } -/** - * Ensures that it is a valid HTTP URL - * It automatically avoids - * - XSS attempts through javascript:alert('hi') - * - mailto: links - */ -function assertValidUrl(url: string | null | undefined) { - if (!url) { - return; - } - - if (!url.startsWith("http://") && !url.startsWith("https://")) { - throw new TRPCError({ code: "PARSE_ERROR", message: "Invalid URL" }); - } -} - function handlePeriodType(periodType: string | undefined): PeriodType | undefined { if (typeof periodType !== "string") return undefined; const passedPeriodType = periodType.toUpperCase(); @@ -102,9 +86,20 @@ const EventTypeUpdateInput = _EventTypeModel export const eventTypesRouter = createProtectedRouter() .query("list", { async resolve({ ctx }) { - return await ctx.prisma.webhook.findMany({ + return await ctx.prisma.eventType.findMany({ where: { userId: ctx.user.id, + team: null, + }, + select: { + id: true, + title: true, + description: true, + length: true, + schedulingType: true, + slug: true, + hidden: true, + metadata: true, }, }); }, @@ -269,7 +264,6 @@ export const eventTypesRouter = createProtectedRouter() hashedLink, ...rest } = input; - assertValidUrl(input.successRedirectUrl); const data: Prisma.EventTypeUpdateInput = rest; data.locations = locations ?? undefined; if (periodType) {