diff --git a/apps/web/components/v2/settings/DisableTwoFactorModal.tsx b/apps/web/components/v2/settings/DisableTwoFactorModal.tsx new file mode 100644 index 0000000000..f8c5b12ac4 --- /dev/null +++ b/apps/web/components/v2/settings/DisableTwoFactorModal.tsx @@ -0,0 +1,107 @@ +import { SyntheticEvent, useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import Button from "@calcom/ui/v2/core/Button"; +import { Dialog, DialogContent } from "@calcom/ui/v2/core/Dialog"; + +import { ErrorCode } from "@lib/auth"; + +import TwoFactorAuthAPI from "./TwoFactorAuthAPI"; + +interface DisableTwoFactorAuthModalProps { + open: boolean; + onOpenChange: () => void; + + /** Called when the user closes the modal without disabling two-factor auth */ + onCancel: () => void; + /** Called when the user disables two-factor auth */ + onDisable: () => void; +} + +const DisableTwoFactorAuthModal = ({ + onDisable, + onCancel, + open, + onOpenChange, +}: DisableTwoFactorAuthModalProps) => { + const [password, setPassword] = useState(""); + const [isDisabling, setIsDisabling] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const { t } = useLocale(); + + async function handleDisable(e: SyntheticEvent) { + e.preventDefault(); + + if (isDisabling) { + return; + } + setIsDisabling(true); + setErrorMessage(null); + + try { + const response = await TwoFactorAuthAPI.disable(password); + if (response.status === 200) { + onDisable(); + return; + } + + const body = await response.json(); + if (body.error === ErrorCode.IncorrectPassword) { + setErrorMessage(t("incorrect_password")); + } else { + setErrorMessage(t("something_went_wrong")); + } + } catch (e) { + setErrorMessage(t("something_went_wrong")); + console.error(t("error_disabling_2fa"), e); + } finally { + setIsDisabling(false); + } + } + + return ( + + +
+
+ +
+ setPassword(e.currentTarget.value)} + className="block w-full rounded-sm border-gray-300 text-sm" + /> +
+ + {errorMessage &&

{errorMessage}

} +
+
+ +
+ + +
+
+
+ ); +}; + +export default DisableTwoFactorAuthModal; diff --git a/apps/web/components/v2/settings/EnableTwoFactorModal.tsx b/apps/web/components/v2/settings/EnableTwoFactorModal.tsx new file mode 100644 index 0000000000..55e0fd524d --- /dev/null +++ b/apps/web/components/v2/settings/EnableTwoFactorModal.tsx @@ -0,0 +1,232 @@ +import React, { SyntheticEvent, useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import Button from "@calcom/ui/v2/core/Button"; +import { Dialog, DialogContent } from "@calcom/ui/v2/core/Dialog"; + +import { ErrorCode } from "@lib/auth"; + +import TwoFactorAuthAPI from "./TwoFactorAuthAPI"; + +interface EnableTwoFactorModalProps { + open: boolean; + onOpenChange: () => void; + + /** + * Called when the user closes the modal without disabling two-factor auth + */ + onCancel: () => void; + + /** + * Called when the user enables two-factor auth + */ + onEnable: () => void; +} + +enum SetupStep { + ConfirmPassword, + DisplayQrCode, + EnterTotpCode, +} + +const WithStep = ({ + step, + current, + children, +}: { + step: SetupStep; + current: SetupStep; + children: JSX.Element; +}) => { + return step === current ? children : null; +}; + +const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: EnableTwoFactorModalProps) => { + const { t } = useLocale(); + const setupDescriptions = { + [SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"), + [SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"), + [SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"), + }; + const [step, setStep] = useState(SetupStep.ConfirmPassword); + const [password, setPassword] = useState(""); + const [totpCode, setTotpCode] = useState(""); + const [dataUri, setDataUri] = useState(""); + const [secret, setSecret] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + async function handleSetup(e: SyntheticEvent) { + e.preventDefault(); + + if (isSubmitting) { + return; + } + + setIsSubmitting(true); + setErrorMessage(null); + + try { + const response = await TwoFactorAuthAPI.setup(password); + const body = await response.json(); + + if (response.status === 200) { + setDataUri(body.dataUri); + setSecret(body.secret); + setStep(SetupStep.DisplayQrCode); + return; + } + + if (body.error === ErrorCode.IncorrectPassword) { + setErrorMessage(t("incorrect_password")); + } else { + setErrorMessage(t("something_went_wrong")); + } + } catch (e) { + setErrorMessage(t("something_went_wrong")); + console.error(t("error_enabling_2fa"), e); + } finally { + setIsSubmitting(false); + } + } + + async function handleEnable(e: SyntheticEvent) { + e.preventDefault(); + + if (isSubmitting || totpCode.length !== 6) { + return; + } + + setIsSubmitting(true); + setErrorMessage(null); + + try { + const response = await TwoFactorAuthAPI.enable(totpCode); + const body = await response.json(); + + if (response.status === 200) { + onEnable(); + return; + } + + if (body.error === ErrorCode.IncorrectTwoFactorCode) { + setErrorMessage(`${t("code_is_incorrect")} ${t("please_try_again")}`); + } else { + setErrorMessage(t("something_went_wrong")); + } + } catch (e) { + setErrorMessage(t("something_went_wrong")); + console.error(t("error_enabling_2fa"), e); + } finally { + setIsSubmitting(false); + } + } + + return ( + + + > + {/* */} + + +
+
+ +
+ setPassword(e.currentTarget.value)} + className="block w-full rounded-sm border-gray-300 text-sm" + /> +
+ + {errorMessage &&

{errorMessage}

} +
+
+
+ + <> +
+ { + // eslint-disable-next-line @next/next/no-img-element + + } +
+

{secret}

+ +
+ +
+
+ +
+ setTotpCode(e.currentTarget.value)} + className="block w-full rounded-sm border-gray-300 text-sm" + autoComplete="one-time-code" + /> +
+ + {errorMessage &&

{errorMessage}

} +
+
+
+ +
+ + + + + + + + + + +
+
+
+ ); +}; + +export default EnableTwoFactorModal; diff --git a/apps/web/components/v2/settings/TwoFactorAuthAPI.ts b/apps/web/components/v2/settings/TwoFactorAuthAPI.ts new file mode 100644 index 0000000000..eb01d59c4c --- /dev/null +++ b/apps/web/components/v2/settings/TwoFactorAuthAPI.ts @@ -0,0 +1,33 @@ +const TwoFactorAuthAPI = { + async setup(password: string) { + return fetch("/api/auth/two-factor/totp/setup", { + method: "POST", + body: JSON.stringify({ password }), + headers: { + "Content-Type": "application/json", + }, + }); + }, + + async enable(code: string) { + return fetch("/api/auth/two-factor/totp/enable", { + method: "POST", + body: JSON.stringify({ code }), + headers: { + "Content-Type": "application/json", + }, + }); + }, + + async disable(password: string) { + return fetch("/api/auth/two-factor/totp/disable", { + method: "POST", + body: JSON.stringify({ password }), + headers: { + "Content-Type": "application/json", + }, + }); + }, +}; + +export default TwoFactorAuthAPI; diff --git a/apps/web/lib/app-providers.tsx b/apps/web/lib/app-providers.tsx index c682bc501d..d60e0c3de7 100644 --- a/apps/web/lib/app-providers.tsx +++ b/apps/web/lib/app-providers.tsx @@ -11,6 +11,7 @@ import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/ import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic"; import { ContractsProvider } from "@calcom/features/ee/web3/contexts/contractsContext"; import { trpc } from "@calcom/trpc/react"; +import { MetaProvider } from "@calcom/ui/v2/core/Meta"; import usePublicPage from "@lib/hooks/usePublicPage"; @@ -80,7 +81,7 @@ const AppProviders = (props: AppPropsWithChildren) => { storageKey={storageKey} forcedTheme={forcedTheme} attribute="class"> - {props.children} + {props.children} diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index ab23777a50..c868a62efd 100644 --- a/apps/web/lib/auth.ts +++ b/apps/web/lib/auth.ts @@ -32,6 +32,7 @@ export enum ErrorCode { InternalServerError = "internal-server-error", NewPasswordMatchesOld = "new-password-matches-old", ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled", + InvalidPassword = "invalid-password", } export const identityProviderNameMap: { [key in IdentityProvider]: string } = { diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index ccf4dc07ac..bfb034c696 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -8,6 +8,7 @@ import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry const V2_WHITELIST = [ "/settings/admin", "/settings/my-account", + "/settings/security", "/availability", "/bookings", "/event-types", diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 65c077d531..18e5507a03 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -161,6 +161,12 @@ const nextConfig = { destination: "/settings/my-account/profile", permanent: false, }, + { + source: "/settings/security", + has: [{ type: "cookie", key: "calcom-v2-early-access" }], + destination: "/settings/security/password", + permanent: false, + }, { source: "/bookings", destination: "/bookings/upcoming", diff --git a/apps/web/pages/v2/settings/admin/apps.tsx b/apps/web/pages/v2/settings/admin/apps.tsx index 883a4bfb4b..e7278c8658 100644 --- a/apps/web/pages/v2/settings/admin/apps.tsx +++ b/apps/web/pages/v2/settings/admin/apps.tsx @@ -1,8 +1,10 @@ +import Meta from "@calcom/ui/v2/core/Meta"; import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout"; function AdminAppsView() { return ( <> +

App listing

); diff --git a/apps/web/pages/v2/settings/admin/impersonation.tsx b/apps/web/pages/v2/settings/admin/impersonation.tsx index 20670c1354..2ea50f175b 100644 --- a/apps/web/pages/v2/settings/admin/impersonation.tsx +++ b/apps/web/pages/v2/settings/admin/impersonation.tsx @@ -4,6 +4,7 @@ import { useRef } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import Button from "@calcom/ui/Button"; import { TextField } from "@calcom/ui/form/fields"; +import Meta from "@calcom/ui/v2/core/Meta"; import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout"; function AdminView() { @@ -12,6 +13,7 @@ function AdminView() { return ( <> +
{ diff --git a/apps/web/pages/v2/settings/admin/index.tsx b/apps/web/pages/v2/settings/admin/index.tsx index 80389d6828..afd4b53888 100644 --- a/apps/web/pages/v2/settings/admin/index.tsx +++ b/apps/web/pages/v2/settings/admin/index.tsx @@ -1,8 +1,10 @@ +import Meta from "@calcom/ui/v2/core/Meta"; import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout"; function AdminAppsView() { return ( <> +

Admin index

); diff --git a/apps/web/pages/v2/settings/admin/users.tsx b/apps/web/pages/v2/settings/admin/users.tsx index e9f2dc851a..bc6e18845d 100644 --- a/apps/web/pages/v2/settings/admin/users.tsx +++ b/apps/web/pages/v2/settings/admin/users.tsx @@ -1,8 +1,10 @@ +import Meta from "@calcom/ui/v2/core/Meta"; import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout"; function AdminUsersView() { return ( <> +

Users listing

); diff --git a/apps/web/pages/v2/settings/my-account/appearance.tsx b/apps/web/pages/v2/settings/my-account/appearance.tsx index 9e151838ec..c59b390288 100644 --- a/apps/web/pages/v2/settings/my-account/appearance.tsx +++ b/apps/web/pages/v2/settings/my-account/appearance.tsx @@ -1,24 +1,17 @@ import { GetServerSidePropsContext } from "next"; import { Trans } from "next-i18next"; -import { useRouter } from "next/router"; -import { title } from "process"; -import { useMemo, useState } from "react"; -import { useForm, Controller } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; -import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import prisma from "@calcom/prisma"; import { trpc } from "@calcom/trpc/react"; -import { Icon } from "@calcom/ui"; -import Avatar from "@calcom/ui/v2/core/Avatar"; import Badge from "@calcom/ui/v2/core/Badge"; import { Button } from "@calcom/ui/v2/core/Button"; -import Loader from "@calcom/ui/v2/core/Loader"; +import Meta from "@calcom/ui/v2/core/Meta"; import Switch from "@calcom/ui/v2/core/Switch"; -import TimezoneSelect from "@calcom/ui/v2/core/TimezoneSelect"; import ColorPicker from "@calcom/ui/v2/core/colorpicker"; import Select from "@calcom/ui/v2/core/form/Select"; -import { TextField, Form, Label } from "@calcom/ui/v2/core/form/fields"; +import { Form } from "@calcom/ui/v2/core/form/fields"; import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout"; import showToast from "@calcom/ui/v2/core/notifications"; @@ -54,6 +47,7 @@ const AppearanceView = (props: inferSSRProps) => { theme: values.theme.value, }); }}> + { }); return ( - { - console.log("🚀 ~ file: calendars.tsx ~ line 28 ~ CalendarsView ~ data", data); - return data.connectedCalendars.length ? ( -
-
-
- + <> + + { + console.log("🚀 ~ file: calendars.tsx ~ line 28 ~ CalendarsView ~ data", data); + return data.connectedCalendars.length ? ( +
+
+
+ +
+

{t("add_to_calendar")}

+

+ + Where to add events when you re booked. You can override this on a per-event basis in + advanced settings in the event type. + +

+
-

{t("add_to_calendar")}

-

- - Where to add events when you re booked. You can override this on a per-event basis in - advanced settings in the event type. - -

- -
-

{t("check_for_conflicts")}

-

{t("select_calendars")}

- - {data.connectedCalendars.map((item) => ( - - {item.calendars && ( - -
- { - // eslint-disable-next-line @next/next/no-img-element - item.integration.logo && ( - {item.integration.title} - ) - } -
- - - {item.integration.name || item.integration.title} - - {data?.destinationCalendar?.credentialId === item.credentialId && ( - Default - )} - - {item.integration.description} +

+ {t("check_for_conflicts")} +

+

{t("select_calendars")}

+ + {data.connectedCalendars.map((item) => ( + + {item.calendars && ( + +
+ { + // eslint-disable-next-line @next/next/no-img-element + item.integration.logo && ( + {item.integration.title} + ) + } +
+ + + {item.integration.name || item.integration.title} + + {data?.destinationCalendar?.credentialId === item.credentialId && ( + Default + )} + + {item.integration.description} +
+
+ +
-
- +
+

+ {t("toggle_calendars_conflict")} +

+
    + {item.calendars.map((cal) => ( + + ))} +
-
-
-

{t("toggle_calendars_conflict")}

-
    - {item.calendars.map((cal) => ( - - ))} -
-
-
- )} -
- ))} -
-
- ) : ( - console.log("Button Clicked")} - /> - ); - }} - /> + + )} + + ))} + +
+ ) : ( + console.log("Button Clicked")} + /> + ); + }} + /> + ); }; diff --git a/apps/web/pages/v2/settings/my-account/conferencing.tsx b/apps/web/pages/v2/settings/my-account/conferencing.tsx index e23b2d61e7..36ab05d2c9 100644 --- a/apps/web/pages/v2/settings/my-account/conferencing.tsx +++ b/apps/web/pages/v2/settings/my-account/conferencing.tsx @@ -9,6 +9,7 @@ import Dropdown, { DropdownMenuItem, DropdownMenuTrigger, } from "@calcom/ui/v2/core/Dropdown"; +import Meta from "@calcom/ui/v2/core/Meta"; import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout"; import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration"; @@ -27,6 +28,7 @@ const ConferencingLayout = (props: inferSSRProps) => return (
+ {apps.map((app) => (
{ weekStart: values.weekStart.value, }); }}> + ) => { handleSubmit={(values) => { mutation.mutate(values); }}> +
{/* TODO upload new avatar */} { + const { t } = useLocale(); + const { data: user } = trpc.useQuery(["viewer.me"]); + + const mutation = trpc.useMutation("viewer.auth.changePassword", { + onSuccess: () => { + showToast(t("password_updated_successfully"), "success"); + }, + onError: (error) => { + showToast(`${t("error_updating_password")}, ${error.message}`, "error"); + }, + }); + + const formMethods = useForm(); + + return ( + <> + + {user && user.identityProvider !== IdentityProvider.CAL ? ( +
+
+

+ {t("account_managed_by_identity_provider", { + provider: identityProviderNameMap[user.identityProvider], + })} +

+
+

+ {t("account_managed_by_identity_provider_description", { + provider: identityProviderNameMap[user.identityProvider], + })} +

+
+ ) : ( + { + const { oldPassword, newPassword } = values; + mutation.mutate({ oldPassword, newPassword }); + }}> +
+ ( + { + formMethods.setValue("oldPassword", e?.target.value); + }} + /> + )} + /> + ( + { + formMethods.setValue("newPassword", e?.target.value); + }} + /> + )} + /> +
+

+ + Password must be at least at least 7 characters, mix of uppercase & lowercase letters, and + contain at least 1 number + +

+ + + )} + + ); +}; + +PasswordView.getLayout = getLayout; + +export default PasswordView; diff --git a/apps/web/pages/v2/settings/security/two-factor-auth.tsx b/apps/web/pages/v2/settings/security/two-factor-auth.tsx new file mode 100644 index 0000000000..061bbfd6cb --- /dev/null +++ b/apps/web/pages/v2/settings/security/two-factor-auth.tsx @@ -0,0 +1,75 @@ +import { useState, useContext } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import Badge from "@calcom/ui/v2/core/Badge"; +import Loader from "@calcom/ui/v2/core/Loader"; +import Meta from "@calcom/ui/v2/core/Meta"; +import Switch from "@calcom/ui/v2/core/Switch"; +import { getLayout } from "@calcom/ui/v2/core/layouts/AdminLayout"; + +import DisableTwoFactorModal from "@components/v2/settings/DisableTwoFactorModal"; +import EnableTwoFactorModal from "@components/v2/settings/EnableTwoFactorModal"; + +const TwoFactorAuthView = () => { + const utils = trpc.useContext(); + + const { t } = useLocale(); + const { data: user, isLoading } = trpc.useQuery(["viewer.me"]); + + const [enableModalOpen, setEnableModalOpen] = useState(false); + const [disableModalOpen, setDisableModalOpen] = useState(false); + + if (isLoading) return ; + + return ( + <> + +
+ + user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true) + } + /> +
+
+

{t("two_factor_auth")}

+ + {user?.twoFactorEnabled ? t("enabled") : t("disabled")} + +
+

Add an extra layer of security to your account.

+
+
+ + setEnableModalOpen(!enableModalOpen)} + onEnable={() => { + setEnableModalOpen(false); + utils.invalidateQueries("viewer.me"); + }} + onCancel={() => { + setEnableModalOpen(false); + }} + /> + + setDisableModalOpen(!disableModalOpen)} + onDisable={() => { + setDisableModalOpen(false); + utils.invalidateQueries("viewer.me"); + }} + onCancel={() => { + setDisableModalOpen(false); + }} + /> + + ); +}; + +TwoFactorAuthView.getLayout = getLayout; + +export default TwoFactorAuthView; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d863b7e839..f6ba309f46 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1079,5 +1079,9 @@ "custom_brand_colors": "Custom brand colors", "customize_your_brand_colors": "Customize your own brand colour into your booking page.", "pro": "Pro", - "removes_cal_branding": "Removes any Cal related brandings, i.e. 'Powered by Cal.'" + "removes_cal_branding": "Removes any Cal related brandings, i.e. 'Powered by Cal.'", + "old_password": "Old password", + "secure_password": "Your new super secure password", + "error_updating_password": "Error updating password", + "two_factor_auth": "Two factor authentication" } diff --git a/packages/lib/auth.ts b/packages/lib/auth.ts index bb067a22b3..00c60f7a47 100644 --- a/packages/lib/auth.ts +++ b/packages/lib/auth.ts @@ -15,6 +15,16 @@ export async function verifyPassword(password: string, hashedPassword: string) { return isValid; } +export function validPassword(password: string) { + if (password.length < 7) return false; + + if (!/[A-Z]/.test(password) || !/[a-z]/.test(password)) return false; + + if (!/\d+/.test(password)) return false; + + return true; +} + export async function getSession(options: GetSessionParams): Promise { const session = await getSessionInner(options); diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index 56a7a0ba22..2667985901 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -38,6 +38,7 @@ import { TRPCError } from "@trpc/server"; import { createProtectedRouter, createRouter } from "../createRouter"; import { apiKeysRouter } from "./viewer/apiKeys"; +import { authRouter } from "./viewer/auth"; import { availabilityRouter } from "./viewer/availability"; import { bookingsRouter } from "./viewer/bookings"; import { eventTypesRouter } from "./viewer/eventTypes"; @@ -1287,6 +1288,7 @@ export const viewerRouter = createRouter() .merge("apiKeys.", apiKeysRouter) .merge("slots.", slotsRouter) .merge("workflows.", workflowsRouter) + .merge("auth.", authRouter) // NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved. // After that there would just one merge call here for all the apps. diff --git a/packages/trpc/server/routers/viewer/auth.tsx b/packages/trpc/server/routers/viewer/auth.tsx new file mode 100644 index 0000000000..2e1d73f033 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth.tsx @@ -0,0 +1,63 @@ +import { IdentityProvider } from "@prisma/client"; +import { z } from "zod"; + +import { hashPassword, verifyPassword, validPassword } from "@calcom/lib/auth"; +import prisma from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import { createProtectedRouter } from "../../createRouter"; + +export const authRouter = createProtectedRouter().mutation("changePassword", { + input: z.object({ + oldPassword: z.string(), + newPassword: z.string(), + }), + async resolve({ input, ctx }) { + const { oldPassword, newPassword } = input; + + const { user } = ctx; + + if (user.identityProvider !== IdentityProvider.CAL) { + throw new TRPCError({ code: "FORBIDDEN", message: "THIRD_PARTY_IDENTITY_PROVIDER_ENABLED" }); + } + + const currentPasswordQuery = await prisma.user.findFirst({ + where: { + id: user.id, + }, + select: { + password: true, + }, + }); + + const currentPassword = currentPasswordQuery?.password; + + if (!currentPassword) { + throw new TRPCError({ code: "NOT_FOUND", message: "MISSING_PASSWORD" }); + } + + const passwordsMatch = await verifyPassword(oldPassword, currentPassword); + if (!passwordsMatch) { + throw new TRPCError({ code: "BAD_REQUEST", message: "INCORRECT_PASSWORD" }); + } + + if (oldPassword === newPassword) { + throw new TRPCError({ code: "BAD_REQUEST", message: "PASSWORD_MATCHES_OLD" }); + } + + if (!validPassword(newPassword)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "INVALID_PASSWORD" }); + } + + const hashedPassword = await hashPassword(newPassword); + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + password: hashedPassword, + }, + }); + }, +}); diff --git a/packages/ui/v2/core/Meta.tsx b/packages/ui/v2/core/Meta.tsx new file mode 100644 index 0000000000..24eb384949 --- /dev/null +++ b/packages/ui/v2/core/Meta.tsx @@ -0,0 +1,56 @@ +import Head from "next/head"; +import React, { createContext, useContext, useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +type MetaType = { + title: string; + description: string; +}; + +const initialMeta = { + title: "", + description: "", +}; + +const MetaContext = createContext({ + meta: initialMeta, + // eslint-disable-next-line @typescript-eslint/no-empty-function + setMeta: (newMeta: Partial) => {}, +}); + +export function useMeta() { + return useContext(MetaContext); +} + +export function MetaProvider({ children }: { children: React.ReactNode }) { + const [value, setValue] = useState(initialMeta); + const setMeta = (newMeta: Partial) => { + setValue((v) => ({ ...v, ...newMeta })); + }; + + return {children}; +} + +/** + * The purpose of this component is to simplify title and description handling. + * Similarly to `next/head`'s `Head` component this allow us to update the metadata for a page + * from any children, also exposes the metadata via the `useMeta` hook in case we need them + * elsewhere (ie. on a Heading, Title, Subtitle, etc.) + * @example + */ +export default function Meta({ title, description }: MetaType) { + const { t } = useLocale(); + const { setMeta, meta } = useMeta(); + /* @TODO: maybe find a way to have this data on first render to prevent flicker */ + if (meta.title !== title || meta.description !== description) { + setMeta({ title, description }); + } + + return ( + + {t(title)} | Cal.com + + + ); +} diff --git a/packages/ui/v2/core/layouts/SettingsLayout.tsx b/packages/ui/v2/core/layouts/SettingsLayout.tsx index 25b8e6b693..97cb6c2834 100644 --- a/packages/ui/v2/core/layouts/SettingsLayout.tsx +++ b/packages/ui/v2/core/layouts/SettingsLayout.tsx @@ -1,6 +1,9 @@ import React, { ComponentProps } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + import { Icon } from "../../../Icon"; +import { useMeta } from "../Meta"; import Shell from "../Shell"; import { VerticalTabItem } from "../navigation/tabs"; import VerticalTabs from "../navigation/tabs/VerticalTabs"; @@ -26,8 +29,8 @@ const tabs = [ icon: Icon.FiKey, children: [ // - { name: "password", href: "/settings/security" }, - { name: "2fa_auth", href: "/settings/security" }, + { name: "password", href: "/settings/security/password" }, + { name: "2fa_auth", href: "/settings/security/two-factor-auth" }, ], }, { @@ -88,9 +91,35 @@ export default function SettingsLayout({ /> }> -
{children}
+
+ + {children} +
); } export const getLayout = (page: React.ReactElement) => {page}; + +function ShellHeader() { + const { meta } = useMeta(); + const { t, isLocaleReady } = useLocale(); + return ( +
+
+ {meta.title && isLocaleReady ? ( +

+ {t(meta.title)} +

+ ) : ( +
+ )} + {meta.description && isLocaleReady ? ( +

{t(meta.description)}

+ ) : ( +
+ )} +
+
+ ); +}