diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml index e848dc36da3..17eb494c985 100644 --- a/.github/workflows/feature-deployment.yml +++ b/.github/workflows/feature-deployment.yml @@ -18,6 +18,11 @@ on: description: "Build Admin" type: boolean default: false + admin-build: + required: false + description: 'Build Admin' + type: boolean + default: false env: BUILD_WEB: ${{ github.event.inputs.web-build }} diff --git a/admin/app/ai/components/ai-config-form.tsx b/admin/app/ai/components/ai-config-form.tsx new file mode 100644 index 00000000000..5290ed1e2ef --- /dev/null +++ b/admin/app/ai/components/ai-config-form.tsx @@ -0,0 +1,128 @@ +import { FC } from "react"; +import { useForm } from "react-hook-form"; +import { Lightbulb } from "lucide-react"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types"; +// components +import { ControllerInput, TControllerInputFormField } from "components/common"; +// hooks +import { useInstance } from "@/hooks"; + +type IInstanceAIForm = { + config: IFormattedInstanceConfiguration; +}; + +type AIFormValues = Record; + +export const InstanceAIForm: FC = (props) => { + const { config } = props; + // store + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + OPENAI_API_KEY: config["OPENAI_API_KEY"], + GPT_ENGINE: config["GPT_ENGINE"], + }, + }); + + const aiFormFields: TControllerInputFormField[] = [ + { + key: "GPT_ENGINE", + type: "text", + label: "GPT_ENGINE", + description: ( + <> + Choose an OpenAI engine.{" "} + + Learn more + + + ), + placeholder: "gpt-3.5-turbo", + error: Boolean(errors.GPT_ENGINE), + required: false, + }, + { + key: "OPENAI_API_KEY", + type: "password", + label: "API key", + description: ( + <> + You will find your API key{" "} + + here. + + + ), + placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd", + error: Boolean(errors.OPENAI_API_KEY), + required: false, + }, + ]; + + const onSubmit = async (formData: AIFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "AI Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+
+
OpenAI
+
If you use ChatGPT, this is for you.
+
+
+ {aiFormFields.map((field) => ( + + ))} +
+
+ +
+ + +
+ +
If you have a preferred AI models vendor, please get in touch with us.
+
+
+
+ ); +}; diff --git a/admin/app/ai/components/index.ts b/admin/app/ai/components/index.ts new file mode 100644 index 00000000000..2a760940100 --- /dev/null +++ b/admin/app/ai/components/index.ts @@ -0,0 +1 @@ +export * from "./ai-config-form"; diff --git a/admin/app/authentication/components/common/authentication-method-card.tsx b/admin/app/authentication/components/common/authentication-method-card.tsx new file mode 100644 index 00000000000..1346a730ec9 --- /dev/null +++ b/admin/app/authentication/components/common/authentication-method-card.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { FC } from "react"; +// helpers +import { cn } from "helpers/common.helper"; + +type Props = { + name: string; + description: string; + icon: JSX.Element; + config: JSX.Element; + disabled?: boolean; + withBorder?: boolean; +}; + +export const AuthenticationMethodCard: FC = (props) => { + const { name, description, icon, config, disabled = false, withBorder = true } = props; + + return ( +
+
+
+
{icon}
+
+
+
+ {name} +
+
+ {description} +
+
+
+
{config}
+
+ ); +}; diff --git a/admin/app/authentication/components/common/index.ts b/admin/app/authentication/components/common/index.ts new file mode 100644 index 00000000000..0f5713cdb90 --- /dev/null +++ b/admin/app/authentication/components/common/index.ts @@ -0,0 +1 @@ +export * from "./authentication-method-card"; diff --git a/admin/app/authentication/github/components/github-config-form.tsx b/admin/app/authentication/github/components/github-config-form.tsx new file mode 100644 index 00000000000..22eb11ff4e7 --- /dev/null +++ b/admin/app/authentication/github/components/github-config-form.tsx @@ -0,0 +1,206 @@ +import { FC, useState } from "react"; +import { useForm } from "react-hook-form"; +import Link from "next/link"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +// components +import { + ConfirmDiscardModal, + ControllerInput, + CopyField, + TControllerInputFormField, + TCopyField, +} from "components/common"; +// types +import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; +// helpers +import { API_BASE_URL, cn } from "helpers/common.helper"; +import isEmpty from "lodash/isEmpty"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type GithubConfigFormValues = Record; + +export const InstanceGithubConfigForm: FC = (props) => { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"], + GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"], + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const githubFormFields: TControllerInputFormField[] = [ + { + key: "GITHUB_CLIENT_ID", + type: "text", + label: "Client ID", + description: ( + <> + You will get this from your{" "} + + GitHub OAuth application settings. + + + ), + placeholder: "70a44354520df8bd9bcd", + error: Boolean(errors.GITHUB_CLIENT_ID), + required: true, + }, + { + key: "GITHUB_CLIENT_SECRET", + type: "password", + label: "Client secret", + description: ( + <> + Your client secret is also found in your{" "} + + GitHub OAuth application settings. + + + ), + placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb", + error: Boolean(errors.GITHUB_CLIENT_SECRET), + required: true, + }, + ]; + + const githubCopyFields: TCopyField[] = [ + { + key: "Origin_URL", + label: "Origin URL", + url: originURL, + description: ( + <> + We will auto-generate this. Paste this into the Authorized origin URL field{" "} + + here. + + + ), + }, + { + key: "Callback_URI", + label: "Callback URI", + url: `${originURL}/auth/github/callback/`, + description: ( + <> + We will auto-generate this. Paste this into your Authorized Callback URI field{" "} + + here. + + + ), + }, + ]; + + const onSubmit = async (formData: GithubConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Github Configuration Settings updated successfully", + }); + reset(); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Configuration
+ {githubFormFields.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Service provider details
+ {githubCopyFields.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/admin/app/authentication/github/components/index.ts b/admin/app/authentication/github/components/index.ts new file mode 100644 index 00000000000..e9e36e988ab --- /dev/null +++ b/admin/app/authentication/github/components/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./github-config-form"; \ No newline at end of file diff --git a/admin/app/authentication/github/components/root.tsx b/admin/app/authentication/github/components/root.tsx new file mode 100644 index 00000000000..742462c3b9d --- /dev/null +++ b/admin/app/authentication/github/components/root.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +// icons +import { Settings2 } from "lucide-react"; +// types +import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +// helpers +import { cn } from "helpers/common.helper"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const GithubConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? ""; + const isGithubConfigured = !!formattedConfig?.GITHUB_CLIENT_ID && !!formattedConfig?.GITHUB_CLIENT_SECRET; + + return ( + <> + {isGithubConfigured ? ( +
+ + Edit + + { + Boolean(parseInt(enableGithubConfig)) === true + ? updateConfig("IS_GITHUB_ENABLED", "0") + : updateConfig("IS_GITHUB_ENABLED", "1"); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/admin/app/authentication/google/components/google-config-form.tsx b/admin/app/authentication/google/components/google-config-form.tsx new file mode 100644 index 00000000000..42cea78fd56 --- /dev/null +++ b/admin/app/authentication/google/components/google-config-form.tsx @@ -0,0 +1,206 @@ +import { FC, useState } from "react"; +import { useForm } from "react-hook-form"; +import Link from "next/link"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +// components +import { + ConfirmDiscardModal, + ControllerInput, + CopyField, + TControllerInputFormField, + TCopyField, +} from "components/common"; +// types +import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; +// helpers +import { API_BASE_URL, cn } from "helpers/common.helper"; +import isEmpty from "lodash/isEmpty"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type GoogleConfigFormValues = Record; + +export const InstanceGoogleConfigForm: FC = (props) => { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"], + GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"], + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const googleFormFields: TControllerInputFormField[] = [ + { + key: "GOOGLE_CLIENT_ID", + type: "text", + label: "Client ID", + description: ( + <> + Your client ID lives in your Google API Console.{" "} + + Learn more + + + ), + placeholder: "840195096245-0p2tstej9j5nc4l8o1ah2dqondscqc1g.apps.googleusercontent.com", + error: Boolean(errors.GOOGLE_CLIENT_ID), + required: true, + }, + { + key: "GOOGLE_CLIENT_SECRET", + type: "password", + label: "Client secret", + description: ( + <> + Your client secret should also be in your Google API Console.{" "} + + Learn more + + + ), + placeholder: "GOCShX-ADp4cI0kPqav1gGCBg5bE02E", + error: Boolean(errors.GOOGLE_CLIENT_SECRET), + required: true, + }, + ]; + + const googleCopyFeilds: TCopyField[] = [ + { + key: "Origin_URL", + label: "Origin URL", + url: originURL, + description: ( +

+ We will auto-generate this. Paste this into your Authorized JavaScript origins field. For this OAuth client{" "} + + here. + +

+ ), + }, + { + key: "Callback_URI", + label: "Callback URI", + url: `${originURL}/auth/google/callback/`, + description: ( +

+ We will auto-generate this. Paste this into your Authorized Redirect URI field. For this OAuth client{" "} + + here. + +

+ ), + }, + ]; + + const onSubmit = async (formData: GoogleConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Google Configuration Settings updated successfully", + }); + reset(); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Configuration
+ {googleFormFields.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Service provider details
+ {googleCopyFeilds.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/admin/app/authentication/google/components/index.ts b/admin/app/authentication/google/components/index.ts new file mode 100644 index 00000000000..d0d37f30570 --- /dev/null +++ b/admin/app/authentication/google/components/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./google-config-form"; \ No newline at end of file diff --git a/admin/app/authentication/google/components/root.tsx b/admin/app/authentication/google/components/root.tsx new file mode 100644 index 00000000000..6b287476dd2 --- /dev/null +++ b/admin/app/authentication/google/components/root.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +// icons +import { Settings2 } from "lucide-react"; +// types +import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +// helpers +import { cn } from "helpers/common.helper"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const GoogleConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? ""; + const isGoogleConfigured = !!formattedConfig?.GOOGLE_CLIENT_ID && !!formattedConfig?.GOOGLE_CLIENT_SECRET; + + return ( + <> + {isGoogleConfigured ? ( +
+ + Edit + + { + Boolean(parseInt(enableGoogleConfig)) === true + ? updateConfig("IS_GOOGLE_ENABLED", "0") + : updateConfig("IS_GOOGLE_ENABLED", "1"); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/admin/app/email/components/email-config-form.tsx b/admin/app/email/components/email-config-form.tsx new file mode 100644 index 00000000000..8bcf9346e0e --- /dev/null +++ b/admin/app/email/components/email-config-form.tsx @@ -0,0 +1,160 @@ +import { FC, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +// hooks +import { useInstance } from "@/hooks"; +// ui +import { Button, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +// components +import { ControllerInput, TControllerInputFormField } from "components/common"; +import { SendTestEmailModal } from "./test-email-modal"; +// types +import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types"; + +type IInstanceEmailForm = { + config: IFormattedInstanceConfiguration; +}; + +type EmailFormValues = Record; + +export const InstanceEmailForm: FC = (props) => { + const { config } = props; + // states + const [isSendTestEmailModalOpen, setIsSendTestEmailModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + watch, + control, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + EMAIL_HOST: config["EMAIL_HOST"], + EMAIL_PORT: config["EMAIL_PORT"], + EMAIL_HOST_USER: config["EMAIL_HOST_USER"], + EMAIL_HOST_PASSWORD: config["EMAIL_HOST_PASSWORD"], + EMAIL_USE_TLS: config["EMAIL_USE_TLS"], + // EMAIL_USE_SSL: config["EMAIL_USE_SSL"], + EMAIL_FROM: config["EMAIL_FROM"], + }, + }); + + const emailFormFields: TControllerInputFormField[] = [ + { + key: "EMAIL_HOST", + type: "text", + label: "Host", + placeholder: "email.google.com", + error: Boolean(errors.EMAIL_HOST), + required: true, + }, + { + key: "EMAIL_PORT", + type: "text", + label: "Port", + placeholder: "8080", + error: Boolean(errors.EMAIL_PORT), + required: true, + }, + { + key: "EMAIL_HOST_USER", + type: "text", + label: "Username", + placeholder: "getitdone@projectplane.so", + error: Boolean(errors.EMAIL_HOST_USER), + required: true, + }, + { + key: "EMAIL_HOST_PASSWORD", + type: "password", + label: "Password", + placeholder: "Password", + error: Boolean(errors.EMAIL_HOST_PASSWORD), + required: true, + }, + { + key: "EMAIL_FROM", + type: "text", + label: "From address", + description: + "This is the email address your users will see when getting emails from this instance. You will need to verify this address.", + placeholder: "no-reply@projectplane.so", + error: Boolean(errors.EMAIL_FROM), + required: true, + }, + ]; + + const onSubmit = async (formData: EmailFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Email Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+ setIsSendTestEmailModalOpen(false)} /> +
+ {emailFormFields.map((field) => ( + + ))} +
+
+
+
+
+ Turn TLS {Boolean(parseInt(watch("EMAIL_USE_TLS"))) ? "off" : "on"} +
+
+ Use this if your email domain supports TLS. +
+
+
+ ( + { + Boolean(parseInt(value)) === true ? onChange("0") : onChange("1"); + }} + size="sm" + /> + )} + /> +
+
+
+
+ +
+ + +
+
+ ); +}; diff --git a/admin/app/email/components/index.ts b/admin/app/email/components/index.ts new file mode 100644 index 00000000000..f06acf7f2df --- /dev/null +++ b/admin/app/email/components/index.ts @@ -0,0 +1,2 @@ +export * from "./email-config-form"; +export * from "./test-email-modal"; diff --git a/admin/app/email/components/test-email-modal.tsx b/admin/app/email/components/test-email-modal.tsx new file mode 100644 index 00000000000..6e9f6d9d3c3 --- /dev/null +++ b/admin/app/email/components/test-email-modal.tsx @@ -0,0 +1,135 @@ +import React, { FC, useEffect, useState } from "react"; +import { Dialog, Transition } from "@headlessui/react"; +// ui +import { Button, Input } from "@plane/ui"; +// services +import { InstanceService } from "services/instance.service"; + +type Props = { + isOpen: boolean; + handleClose: () => void; +}; + +enum ESendEmailSteps { + SEND_EMAIL = "SEND_EMAIL", + SUCCESS = "SUCCESS", + FAILED = "FAILED", +} + +const instanceService = new InstanceService(); + +export const SendTestEmailModal: FC = (props) => { + const { isOpen, handleClose } = props; + + // state + const [receiverEmail, setReceiverEmail] = useState(""); + const [sendEmailStep, setSendEmailStep] = useState(ESendEmailSteps.SEND_EMAIL); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + // reset state + const resetState = () => { + setReceiverEmail(""); + setSendEmailStep(ESendEmailSteps.SEND_EMAIL); + setIsLoading(false); + setError(""); + }; + + useEffect(() => { + if (!isOpen) { + resetState(); + } + }, [isOpen]); + + const handleSubmit = async (e: React.MouseEvent) => { + e.preventDefault(); + + setIsLoading(true); + await instanceService + .sendTestEmail(receiverEmail) + .then(() => { + setSendEmailStep(ESendEmailSteps.SUCCESS); + }) + .catch((error) => { + setError(error?.message || "Failed to send email"); + setSendEmailStep(ESendEmailSteps.FAILED); + }) + .finally(() => { + setIsLoading(false); + }); + }; + + return ( + + + +
+ +
+
+ + +

+ {sendEmailStep === ESendEmailSteps.SEND_EMAIL + ? "Send test email" + : sendEmailStep === ESendEmailSteps.SUCCESS + ? "Email send" + : "Failed"}{" "} +

+
+ {sendEmailStep === ESendEmailSteps.SEND_EMAIL && ( + setReceiverEmail(e.target.value)} + placeholder="Receiver email" + className="w-full resize-none text-lg" + tabIndex={1} + /> + )} + {sendEmailStep === ESendEmailSteps.SUCCESS && ( +
+

+ We have sent the test email to {receiverEmail}. Please check your spam folder if you cannot find + it. +

+

If you still cannot find it, recheck your SMTP configuration and trigger a new test email.

+
+ )} + {sendEmailStep === ESendEmailSteps.FAILED &&
{error}
} +
+ + {sendEmailStep === ESendEmailSteps.SEND_EMAIL && ( + + )} +
+
+
+
+
+
+
+
+ ); +}; diff --git a/admin/app/general/components/general-config-form.tsx b/admin/app/general/components/general-config-form.tsx new file mode 100644 index 00000000000..f45876419c0 --- /dev/null +++ b/admin/app/general/components/general-config-form.tsx @@ -0,0 +1,136 @@ +import { FC } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { Telescope } from "lucide-react"; +import { IInstance, IInstanceAdmin } from "@plane/types"; +import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +// components +import { ControllerInput } from "components/common"; +// hooks +import { useInstance } from "@/hooks"; + +export interface IGeneralConfigurationForm { + instance: IInstance["instance"]; + instanceAdmins: IInstanceAdmin[]; +} + +export const GeneralConfigurationForm: FC = (props) => { + const { instance, instanceAdmins } = props; + // hooks + const { updateInstanceInfo } = useInstance(); + // form data + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm>({ + defaultValues: { + instance_name: instance.instance_name, + is_telemetry_enabled: instance.is_telemetry_enabled, + }, + }); + + const onSubmit = async (formData: Partial) => { + const payload: Partial = { ...formData }; + + console.log("payload", payload); + + await updateInstanceInfo(payload) + .then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+
Instance details
+
+ + +
+

Email

+ +
+ +
+

Instance ID

+ +
+
+
+ +
+
Telemetry
+
+
+
+
+ +
+
+
+
+ Allow Plane to collect anonymous usage events +
+
+ We collect usage events without any PII to analyse and improve Plane.{" "} + + Know more. + +
+
+
+
+ ( + + )} + /> +
+
+
+ +
+ +
+
+ ); +}; diff --git a/admin/app/general/components/index.ts b/admin/app/general/components/index.ts new file mode 100644 index 00000000000..a144f8d63da --- /dev/null +++ b/admin/app/general/components/index.ts @@ -0,0 +1 @@ +export * from "./general-config-form"; \ No newline at end of file diff --git a/admin/app/image/components/image-config-form.tsx b/admin/app/image/components/image-config-form.tsx new file mode 100644 index 00000000000..722051878bf --- /dev/null +++ b/admin/app/image/components/image-config-form.tsx @@ -0,0 +1,79 @@ +import { FC } from "react"; +import { useForm } from "react-hook-form"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types"; +// components +import { ControllerInput } from "components/common"; +// hooks +import { useInstance } from "@/hooks"; + +type IInstanceImageConfigForm = { + config: IFormattedInstanceConfiguration; +}; + +type ImageConfigFormValues = Record; + +export const InstanceImageConfigForm: FC = (props) => { + const { config } = props; + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + UNSPLASH_ACCESS_KEY: config["UNSPLASH_ACCESS_KEY"], + }, + }); + + const onSubmit = async (formData: ImageConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Image Configuration Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+ + You will find your access key in your Unsplash developer console.  + + Learn more. + + + } + placeholder="oXgq-sdfadsaeweqasdfasdf3234234rassd" + error={Boolean(errors.UNSPLASH_ACCESS_KEY)} + required + /> +
+ +
+ +
+
+ ); +}; diff --git a/admin/app/image/components/index.ts b/admin/app/image/components/index.ts new file mode 100644 index 00000000000..ad9b60a1023 --- /dev/null +++ b/admin/app/image/components/index.ts @@ -0,0 +1 @@ +export * from "./image-config-form"; \ No newline at end of file diff --git a/admin/app/login/components/index.ts b/admin/app/login/components/index.ts new file mode 100644 index 00000000000..bdeb387f3fb --- /dev/null +++ b/admin/app/login/components/index.ts @@ -0,0 +1 @@ +export * from "./sign-in-form"; diff --git a/admin/app/login/components/sign-in-form.tsx b/admin/app/login/components/sign-in-form.tsx new file mode 100644 index 00000000000..ba0883c83c9 --- /dev/null +++ b/admin/app/login/components/sign-in-form.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { FC, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +// services +import { AuthService } from "@/services/auth.service"; +// ui +import { Button, Input } from "@plane/ui"; +// components +import { Banner } from "components/common"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// helpers +import { API_BASE_URL, cn } from "@/helpers/common.helper"; + +// service initialization +const authService = new AuthService(); + +// error codes +enum EErrorCodes { + INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED", + REQUIRED_EMAIL_PASSWORD = "REQUIRED_EMAIL_PASSWORD", + INVALID_EMAIL = "INVALID_EMAIL", + USER_DOES_NOT_EXIST = "USER_DOES_NOT_EXIST", + AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED", +} + +type TError = { + type: EErrorCodes | undefined; + message: string | undefined; +}; + +// form data +type TFormData = { + email: string; + password: string; +}; + +const defaultFromData: TFormData = { + email: "", + password: "", +}; + +export const InstanceSignInForm: FC = (props) => { + const {} = props; + // search params + const searchParams = useSearchParams(); + const emailParam = searchParams.get("email") || undefined; + const errorCode = searchParams.get("error_code") || undefined; + const errorMessage = searchParams.get("error_message") || undefined; + // state + const [showPassword, setShowPassword] = useState(false); + const [csrfToken, setCsrfToken] = useState(undefined); + const [formData, setFormData] = useState(defaultFromData); + + const handleFormChange = (key: keyof TFormData, value: string | boolean) => + setFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + useEffect(() => { + if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam })); + }, [emailParam]); + + // derived values + const errorData: TError = useMemo(() => { + if (errorCode && errorMessage) { + switch (errorCode) { + case EErrorCodes.INSTANCE_NOT_CONFIGURED: + return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage }; + case EErrorCodes.REQUIRED_EMAIL_PASSWORD: + return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD, message: errorMessage }; + case EErrorCodes.INVALID_EMAIL: + return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage }; + case EErrorCodes.USER_DOES_NOT_EXIST: + return { type: EErrorCodes.USER_DOES_NOT_EXIST, message: errorMessage }; + case EErrorCodes.AUTHENTICATION_FAILED: + return { type: EErrorCodes.AUTHENTICATION_FAILED, message: errorMessage }; + default: + return { type: undefined, message: undefined }; + } + } else return { type: undefined, message: undefined }; + }, [errorCode, errorMessage]); + + const isButtonDisabled = useMemo(() => (formData.email && formData.password ? false : true), [formData]); + + return ( +
+
+
+

Manage your Plane instance

+

Configure instance-wide settings to secure your instance

+
+ + {errorData.type && errorData?.message && } + +
+ + +
+ + handleFormChange("email", e.target.value)} + /> +
+ +
+ +
+ handleFormChange("password", e.target.value)} + /> + {showPassword ? ( + + ) : ( + + )} +
+
+
+ +
+
+
+
+ ); +}; diff --git a/admin/app/login/layout.tsx b/admin/app/login/layout.tsx new file mode 100644 index 00000000000..84152390fa5 --- /dev/null +++ b/admin/app/login/layout.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { ReactNode } from "react"; +// lib +import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +// helpers +import { EAuthenticationPageType, EInstancePageType } from "@/helpers"; + +interface LoginLayoutProps { + children: ReactNode; +} + +const LoginLayout = ({ children }: LoginLayoutProps) => ( + + {children} + +); + +export default LoginLayout; diff --git a/admin/app/login/page.tsx b/admin/app/login/page.tsx new file mode 100644 index 00000000000..e7edc3fd76b --- /dev/null +++ b/admin/app/login/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +// layouts +import { DefaultLayout } from "@/layouts"; +// components +import { PageHeader } from "@/components/core"; +import { InstanceSignInForm } from "./components"; + +const LoginPage = () => ( + <> + + + + + +); + +export default LoginPage; diff --git a/admin/app/setup/components/index.ts b/admin/app/setup/components/index.ts new file mode 100644 index 00000000000..558353b2ea9 --- /dev/null +++ b/admin/app/setup/components/index.ts @@ -0,0 +1 @@ +export * from "./sign-up-form"; diff --git a/admin/app/setup/components/sign-up-form.tsx b/admin/app/setup/components/sign-up-form.tsx new file mode 100644 index 00000000000..d700ce62c73 --- /dev/null +++ b/admin/app/setup/components/sign-up-form.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { FC, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +// services +import { AuthService } from "@/services/auth.service"; +// ui +import { Button, Checkbox, Input } from "@plane/ui"; +// components +import { Banner, PasswordStrengthMeter } from "components/common"; +// icons +import { Eye, EyeOff } from "lucide-react"; +// helpers +import { API_BASE_URL, cn } from "@/helpers/common.helper"; +import { getPasswordStrength } from "@/helpers/password.helper"; + +// service initialization +const authService = new AuthService(); + +// error codes +enum EErrorCodes { + INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED", + ADMIN_ALREADY_EXIST = "ADMIN_ALREADY_EXIST", + REQUIRED_EMAIL_PASSWORD_FIRST_NAME = "REQUIRED_EMAIL_PASSWORD_FIRST_NAME", + INVALID_EMAIL = "INVALID_EMAIL", + INVALID_PASSWORD = "INVALID_PASSWORD", + USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS", +} + +type TError = { + type: EErrorCodes | undefined; + message: string | undefined; +}; + +// form data +type TFormData = { + first_name: string; + last_name: string; + email: string; + company_name: string; + password: string; + is_telemetry_enabled: boolean; +}; + +const defaultFromData: TFormData = { + first_name: "", + last_name: "", + email: "", + company_name: "", + password: "", + is_telemetry_enabled: true, +}; + +export const InstanceSignUpForm: FC = (props) => { + const {} = props; + // search params + const searchParams = useSearchParams(); + const firstNameParam = searchParams.get("first_name") || undefined; + const lastNameParam = searchParams.get("last_name") || undefined; + const companyParam = searchParams.get("company") || undefined; + const emailParam = searchParams.get("email") || undefined; + const isTelemetryEnabledParam = (searchParams.get("is_telemetry_enabled") === "True" ? true : false) || true; + const errorCode = searchParams.get("error_code") || undefined; + const errorMessage = searchParams.get("error_message") || undefined; + // state + const [showPassword, setShowPassword] = useState(false); + const [csrfToken, setCsrfToken] = useState(undefined); + const [formData, setFormData] = useState(defaultFromData); + + const handleFormChange = (key: keyof TFormData, value: string | boolean) => + setFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + useEffect(() => { + if (firstNameParam) setFormData((prev) => ({ ...prev, first_name: firstNameParam })); + if (lastNameParam) setFormData((prev) => ({ ...prev, last_name: lastNameParam })); + if (companyParam) setFormData((prev) => ({ ...prev, company_name: companyParam })); + if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam })); + if (isTelemetryEnabledParam) setFormData((prev) => ({ ...prev, is_telemetry_enabled: isTelemetryEnabledParam })); + }, [firstNameParam, lastNameParam, companyParam, emailParam, isTelemetryEnabledParam]); + + // derived values + const errorData: TError = useMemo(() => { + if (errorCode && errorMessage) { + switch (errorCode) { + case EErrorCodes.INSTANCE_NOT_CONFIGURED: + return { type: EErrorCodes.INSTANCE_NOT_CONFIGURED, message: errorMessage }; + case EErrorCodes.ADMIN_ALREADY_EXIST: + return { type: EErrorCodes.ADMIN_ALREADY_EXIST, message: errorMessage }; + case EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME: + return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME, message: errorMessage }; + case EErrorCodes.INVALID_EMAIL: + return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage }; + case EErrorCodes.INVALID_PASSWORD: + return { type: EErrorCodes.INVALID_PASSWORD, message: errorMessage }; + case EErrorCodes.USER_ALREADY_EXISTS: + return { type: EErrorCodes.USER_ALREADY_EXISTS, message: errorMessage }; + default: + return { type: undefined, message: undefined }; + } + } else return { type: undefined, message: undefined }; + }, [errorCode, errorMessage]); + + const isButtonDisabled = useMemo( + () => + formData.first_name && formData.email && formData.password && getPasswordStrength(formData.password) >= 3 + ? false + : true, + [formData] + ); + + return ( +
+
+
+

Setup your Plane Instance

+

Post setup you will be able to manage this Plane instance.

+
+ + {errorData.type && + errorData?.message && + ![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && ( + + )} + +
+ + +
+
+ + handleFormChange("first_name", e.target.value)} + /> +
+
+ + handleFormChange("last_name", e.target.value)} + /> +
+
+ +
+ + handleFormChange("email", e.target.value)} + hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false} + /> + {errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && ( +

{errorData.message}

+ )} +
+ +
+ + handleFormChange("company_name", e.target.value)} + /> +
+ +
+ +
+ handleFormChange("password", e.target.value)} + hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false} + /> + {showPassword ? ( + + ) : ( + + )} +
+ {errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && ( +

{errorData.message}

+ )} + +
+ +
+ handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} + checked={formData.is_telemetry_enabled} + /> + + + See More + +
+ +
+ +
+
+
+
+ ); +}; diff --git a/admin/app/setup/layout.tsx b/admin/app/setup/layout.tsx new file mode 100644 index 00000000000..07f42cd7141 --- /dev/null +++ b/admin/app/setup/layout.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { ReactNode } from "react"; +// lib +import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers"; +// helpers +import { EAuthenticationPageType, EInstancePageType } from "@/helpers"; + +interface SetupLayoutProps { + children: ReactNode; +} + +const SetupLayout = ({ children }: SetupLayoutProps) => ( + + {children} + +); + +export default SetupLayout; diff --git a/admin/app/setup/page.tsx b/admin/app/setup/page.tsx new file mode 100644 index 00000000000..641155c85d1 --- /dev/null +++ b/admin/app/setup/page.tsx @@ -0,0 +1,16 @@ +// layouts +import { DefaultLayout } from "@/layouts"; +// components +import { PageHeader } from "@/components/core"; +import { InstanceSignUpForm } from "./components"; + +const SetupPage = () => ( + <> + + + + + +); + +export default SetupPage; diff --git a/admin/components/create-workspace-popup.tsx b/admin/components/create-workspace-popup.tsx new file mode 100644 index 00000000000..1b73860c4ed --- /dev/null +++ b/admin/components/create-workspace-popup.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +// ui +import { Button, getButtonStyling } from "@plane/ui"; +// helpers +import { resolveGeneralTheme } from "helpers/common.helper"; +// icons +import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg"; +import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg"; + +type Props = { + isOpen: boolean; + onClose?: () => void; +}; + +export const CreateWorkspacePopup: React.FC = observer((props) => { + const { isOpen, onClose } = props; + // theme + const { resolvedTheme } = useTheme(); + + const handleClose = () => { + onClose && onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
+
+
Create workspace
+
+ Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first + workspace, you will need to login again. +
+
+ + Create workspace + + +
+
+
+ Plane icon +
+
+
+ ); +}); diff --git a/admin/hooks/index.ts b/admin/hooks/index.ts new file mode 100644 index 00000000000..273970eda12 --- /dev/null +++ b/admin/hooks/index.ts @@ -0,0 +1,6 @@ +export * from "./use-outside-click-detector"; + +// store-hooks +export * from "./store/use-theme"; +export * from "./store/use-instance"; +export * from "./store/use-user"; diff --git a/admin/layouts/index.ts b/admin/layouts/index.ts new file mode 100644 index 00000000000..5e4a7c0238a --- /dev/null +++ b/admin/layouts/index.ts @@ -0,0 +1,2 @@ +export * from "./default-layout"; +export * from "./admin-layout"; diff --git a/admin/lib/store-context.tsx b/admin/lib/store-context.tsx new file mode 100644 index 00000000000..37bba1a713a --- /dev/null +++ b/admin/lib/store-context.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactElement, createContext } from "react"; +// mobx store +import { RootStore } from "@/store/root-store"; + +let rootStore = new RootStore(); + +export const StoreContext = createContext(rootStore); + +const initializeStore = () => { + const newRootStore = rootStore ?? new RootStore(); + if (typeof window === "undefined") return newRootStore; + if (!rootStore) rootStore = newRootStore; + return newRootStore; +}; + +export const StoreProvider = ({ children }: { children: ReactElement }) => { + const store = initializeStore(); + return {children}; +}; diff --git a/admin/lib/wrappers/app-wrapper.tsx b/admin/lib/wrappers/app-wrapper.tsx new file mode 100644 index 00000000000..6be1cec246c --- /dev/null +++ b/admin/lib/wrappers/app-wrapper.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { FC, ReactNode, useEffect, Suspense } from "react"; +import { observer } from "mobx-react-lite"; +import { SWRConfig } from "swr"; +// hooks +import { useTheme, useUser } from "@/hooks"; +// ui +import { Toast } from "@plane/ui"; +// constants +import { SWR_CONFIG } from "constants/swr-config"; +// helpers +import { resolveGeneralTheme } from "helpers/common.helper"; + +interface IAppWrapper { + children: ReactNode; +} + +export const AppWrapper: FC = observer(({ children }) => { + // hooks + const { theme, isSidebarCollapsed, toggleSidebar } = useTheme(); + const { currentUser } = useUser(); + + useEffect(() => { + const localValue = localStorage && localStorage.getItem("god_mode_sidebar_collapsed"); + const localBoolValue = localValue ? (localValue === "true" ? true : false) : false; + if (isSidebarCollapsed === undefined && localBoolValue != isSidebarCollapsed) toggleSidebar(localBoolValue); + }, [isSidebarCollapsed, currentUser, toggleSidebar]); + + return ( + + + {children} + + ); +}); diff --git a/admin/lib/wrappers/auth-wrapper.tsx b/admin/lib/wrappers/auth-wrapper.tsx new file mode 100644 index 00000000000..bd3770376f1 --- /dev/null +++ b/admin/lib/wrappers/auth-wrapper.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +import { Spinner } from "@plane/ui"; +// hooks +import { useInstance, useUser } from "@/hooks"; +// helpers +import { EAuthenticationPageType, EUserStatus } from "@/helpers"; +import { redirect } from "next/navigation"; + +export interface IAuthWrapper { + children: ReactNode; + authType?: EAuthenticationPageType; +} + +export const AuthWrapper: FC = observer((props) => { + const { children, authType = EAuthenticationPageType.AUTHENTICATED } = props; + // hooks + const { instance, fetchInstanceAdmins } = useInstance(); + const { isLoading, userStatus, currentUser, fetchCurrentUser } = useUser(); + + useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), { + shouldRetryOnError: false, + }); + useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins(), { + shouldRetryOnError: false, + }); + + if (isLoading) + return ( +
+ +
+ ); + + if (userStatus && userStatus?.status === EUserStatus.ERROR) + return ( +
+ Something went wrong. please try again later +
+ ); + + if ([EAuthenticationPageType.AUTHENTICATED, EAuthenticationPageType.NOT_AUTHENTICATED].includes(authType)) { + if (authType === EAuthenticationPageType.NOT_AUTHENTICATED) { + if (currentUser === undefined) return <>{children}; + else redirect("/general/"); + } else { + if (currentUser) return <>{children}; + else { + if (instance?.instance?.is_setup_done) redirect("/login/"); + else redirect("/setup/"); + } + } + } + + return <>{children}; +}); diff --git a/admin/lib/wrappers/index.ts b/admin/lib/wrappers/index.ts new file mode 100644 index 00000000000..81c379624ff --- /dev/null +++ b/admin/lib/wrappers/index.ts @@ -0,0 +1,3 @@ +export * from "./app-wrapper"; +export * from "./instance-wrapper"; +export * from "./auth-wrapper"; diff --git a/admin/lib/wrappers/instance-wrapper.tsx b/admin/lib/wrappers/instance-wrapper.tsx new file mode 100644 index 00000000000..4edbcbde4fa --- /dev/null +++ b/admin/lib/wrappers/instance-wrapper.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { redirect, useSearchParams } from "next/navigation"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +import { Spinner } from "@plane/ui"; +// layouts +import { DefaultLayout } from "@/layouts"; +// components +import { InstanceNotReady } from "@/components/instance"; +// hooks +import { useInstance } from "@/hooks"; +// helpers +import { EInstancePageType, EInstanceStatus } from "@/helpers"; + +type TInstanceWrapper = { + children: ReactNode; + pageType?: EInstancePageType; +}; + +export const InstanceWrapper: FC = observer((props) => { + const { children, pageType } = props; + const searchparams = useSearchParams(); + const authEnabled = searchparams.get("auth_enabled") || "1"; + // hooks + const { isLoading, instanceStatus, instance, fetchInstanceInfo } = useInstance(); + + useSWR("INSTANCE_INFORMATION", () => fetchInstanceInfo(), { + revalidateOnFocus: false, + }); + + if (isLoading) + return ( +
+ +
+ ); + + if (instanceStatus && instanceStatus?.status === EInstanceStatus.ERROR) + return ( +
+ Something went wrong. please try again later +
+ ); + + if (instance?.instance?.is_setup_done === false && authEnabled === "1") + return ( + + + + ); + + if (instance?.instance?.is_setup_done && pageType === EInstancePageType.PRE_SETUP) redirect("/"); + if (!instance?.instance?.is_setup_done && pageType === EInstancePageType.POST_SETUP) redirect("/setup"); + + return <>{children}; +}); diff --git a/admin/services/index.ts b/admin/services/index.ts new file mode 100644 index 00000000000..57313a87fb3 --- /dev/null +++ b/admin/services/index.ts @@ -0,0 +1,3 @@ +export * from "./auth.service"; +export * from "./instance.service"; +export * from "./user.service"; diff --git a/admin/store/root-store.ts b/admin/store/root-store.ts new file mode 100644 index 00000000000..85b2a5a8b58 --- /dev/null +++ b/admin/store/root-store.ts @@ -0,0 +1,25 @@ +import { enableStaticRendering } from "mobx-react-lite"; +// stores +import { IThemeStore, ThemeStore } from "./theme.store"; +import { IInstanceStore, InstanceStore } from "./instance.store"; +import { IUserStore, UserStore } from "./user.store"; + +enableStaticRendering(typeof window === "undefined"); + +export class RootStore { + theme: IThemeStore; + instance: IInstanceStore; + user: IUserStore; + + constructor() { + this.theme = new ThemeStore(this); + this.instance = new InstanceStore(this); + this.user = new UserStore(this); + } + + resetOnSignOut() { + this.theme = new ThemeStore(this); + this.instance = new InstanceStore(this); + this.user = new UserStore(this); + } +} diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py index 92e82d01265..730d388f425 100644 --- a/apiserver/plane/license/api/serializers/instance.py +++ b/apiserver/plane/license/api/serializers/instance.py @@ -5,9 +5,6 @@ class InstanceSerializer(BaseSerializer): - primary_owner_details = UserAdminLiteSerializer( - source="primary_owner", read_only=True - ) class Meta: model = Instance diff --git a/apiserver/plane/license/migrations/0002_instance_is_telemetry_anonymous.py b/apiserver/plane/license/migrations/0002_instance_is_telemetry_anonymous.py new file mode 100644 index 00000000000..a1a566458e7 --- /dev/null +++ b/apiserver/plane/license/migrations/0002_instance_is_telemetry_anonymous.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.10 on 2024-03-11 15:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('license', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='instance', + name='is_telemetry_anonymous', + field=models.BooleanField(default=False), + ), + ] diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py index b8957e44fa4..cad77ea1a83 100644 --- a/apiserver/plane/license/models/instance.py +++ b/apiserver/plane/license/models/instance.py @@ -1,6 +1,6 @@ # Django imports -from django.db import models from django.conf import settings +from django.db import models # Module imports from plane.db.models import BaseModel @@ -21,6 +21,7 @@ class Instance(BaseModel): namespace = models.CharField(max_length=50, blank=True, null=True) # telemetry and support is_telemetry_enabled = models.BooleanField(default=True) + is_telemetry_anonymous = models.BooleanField(default=False) is_support_required = models.BooleanField(default=True) # is setup done is_setup_done = models.BooleanField(default=False) diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index a6bd2ab50d8..e7651ebfc23 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -60,4 +60,4 @@ zxcvbn==4.4.28 # timezone pytz==2024.1 # jwt -PyJWT==2.8.0 \ No newline at end of file +PyJWT==2.8.0 diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 4d98ec7c91e..1c5949659f3 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -66,6 +66,18 @@ services: - api - worker - web + + admin: + <<: *app-env + image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable} + pull_policy: ${PULL_POLICY:-always} + restart: unless-stopped + command: node admin/server.js admin + deploy: + replicas: ${ADMIN_REPLICAS:-1} + depends_on: + - api + - web admin: <<: *app-env diff --git a/docker-compose.yml b/docker-compose.yml index be100819385..4cf4dfa442e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -134,6 +134,7 @@ services: MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} + # Comment this if you already have a reverse proxy running proxy: container_name: proxy diff --git a/nginx/nginx.conf.dev b/nginx/nginx.conf.dev index a6f20cbf5b1..87deaf9b2fb 100644 --- a/nginx/nginx.conf.dev +++ b/nginx/nginx.conf.dev @@ -36,6 +36,10 @@ http { proxy_pass http://admin:3001/god-mode/; } + location /god-mode { + proxy_pass http://godmode:3000/; + } + location /api/ { proxy_http_version 1.1; proxy_set_header Upgrade ${dollar}http_upgrade; diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index 56fdae8dc19..4fd67ad0c6d 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -60,6 +60,10 @@ http { proxy_set_header Host ${dollar}http_host; proxy_pass http://space:3000/spaces/; } + + location /god-mode/ { + proxy_pass http://admin:3000/god-mode/; + } location /${BUCKET_NAME}/ { proxy_http_version 1.1; @@ -69,4 +73,4 @@ http { proxy_pass http://plane-minio:9000/uploads/; } } -} +} \ No newline at end of file diff --git a/space/components/account/auth-forms/forgot-password-popover.tsx b/space/components/account/auth-forms/forgot-password-popover.tsx new file mode 100644 index 00000000000..31bafce26b5 --- /dev/null +++ b/space/components/account/auth-forms/forgot-password-popover.tsx @@ -0,0 +1,54 @@ +import { Fragment, useState } from "react"; +import { usePopper } from "react-popper"; +import { X } from "lucide-react"; +import { Popover } from "@headlessui/react"; + +export const ForgotPasswordPopover = () => { + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "right-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + return ( + + + + + + {({ close }) => ( +
+ 🤥 +

+ We see that your god hasn{"'"}t enabled SMTP, we will not be able to send a password reset link +

+ +
+ )} +
+
+ ); +}; diff --git a/space/components/account/auth-forms/root.tsx b/space/components/account/auth-forms/root.tsx new file mode 100644 index 00000000000..1fce06d18cc --- /dev/null +++ b/space/components/account/auth-forms/root.tsx @@ -0,0 +1,175 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { IEmailCheckData } from "@plane/types"; +import { EmailForm, UniqueCodeForm, PasswordForm, OAuthOptions, TermsAndConditions } from "@/components/accounts"; +// hooks +import useToast from "@/hooks/use-toast"; +import { useMobxStore } from "@/lib/mobx/store-provider"; +// services +import { AuthService } from "@/services/authentication.service"; + +export enum EAuthSteps { + EMAIL = "EMAIL", + PASSWORD = "PASSWORD", + UNIQUE_CODE = "UNIQUE_CODE", +} + +export enum EAuthModes { + SIGN_IN = "SIGN_IN", + SIGN_UP = "SIGN_UP", +} + +type TTitle = { + header: string; + subHeader: string; +}; + +type THeaderSubheader = { + [mode in EAuthModes]: { + [step in Exclude]: TTitle; + }; +}; + +const Titles: THeaderSubheader = { + [EAuthModes.SIGN_IN]: { + [EAuthSteps.PASSWORD]: { + header: "Sign in to Plane", + subHeader: "Get back to your projects and make progress", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Sign in to Plane", + subHeader: "Get back to your projects and make progress", + }, + }, + [EAuthModes.SIGN_UP]: { + [EAuthSteps.PASSWORD]: { + header: "Create your account", + subHeader: "Progress, visualize, and measure work how it works best for you.", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Create your account", + subHeader: "Progress, visualize, and measure work how it works best for you.", + }, + }, +}; + +// TODO: Better approach for this. +const getHeaderSubHeader = (mode: EAuthModes | null, step: EAuthSteps): TTitle => { + if (mode) { + return (Titles[mode] as any)[step]; + } + + return { + header: "Get started with Plane", + subHeader: "Progress, visualize, and measure work how it works best for you.", + }; +}; + +const authService = new AuthService(); + +export const AuthRoot = observer(() => { + const { setToastAlert } = useToast(); + // states + const [authMode, setAuthMode] = useState(null); + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(""); + // hooks + const { + instanceStore: { instance }, + } = useMobxStore(); + // derived values + const isSmtpConfigured = instance?.config?.is_smtp_configured; + + const { header, subHeader } = getHeaderSubHeader(authMode, authStep); + + const handelEmailVerification = async (data: IEmailCheckData) => { + // update the global email state + setEmail(data.email); + + await authService + .emailCheck(data) + .then((res) => { + // Set authentication mode based on user existing status. + if (res.existing) { + setAuthMode(EAuthModes.SIGN_IN); + } else { + setAuthMode(EAuthModes.SIGN_UP); + } + + // If user exists and password is already setup by the user, move to password sign in. + if (res.existing && !res.is_password_autoset) { + setAuthStep(EAuthSteps.PASSWORD); + } else { + // Else if SMTP is configured, move to unique code sign-in/ sign-up. + if (isSmtpConfigured) { + setAuthStep(EAuthSteps.UNIQUE_CODE); + } else { + // Else show error message if SMTP is not configured and password is not set. + if (res.existing) { + setAuthMode(null); + setToastAlert({ + type: "error", + title: "Error!", + message: "Unable to process request please contact Administrator to reset password", + }); + } else { + // If SMTP is not configured and user is new, move to password sign-up. + setAuthStep(EAuthSteps.PASSWORD); + } + } + } + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const isOAuthEnabled = + instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled); + return ( + <> +
+
+

{header}

+

{subHeader}

+
+ {authStep === EAuthSteps.EMAIL && } + {authMode && ( + <> + {authStep === EAuthSteps.PASSWORD && ( + { + setEmail(""); + setAuthMode(null); + setAuthStep(EAuthSteps.EMAIL); + }} + handleStepChange={(step) => setAuthStep(step)} + /> + )} + {authStep === EAuthSteps.UNIQUE_CODE && ( + { + setEmail(""); + setAuthMode(null); + setAuthStep(EAuthSteps.EMAIL); + }} + submitButtonText="Continue" + /> + )} + + )} +
+ {isOAuthEnabled && } + + + ); +}); diff --git a/space/components/account/password-strength-meter.tsx b/space/components/account/password-strength-meter.tsx new file mode 100644 index 00000000000..86ee814c895 --- /dev/null +++ b/space/components/account/password-strength-meter.tsx @@ -0,0 +1,67 @@ +// icons +import { CircleCheck } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { getPasswordStrength } from "@/helpers/password.helper"; + +type Props = { + password: string; +}; + +export const PasswordStrengthMeter: React.FC = (props: Props) => { + const { password } = props; + + const strength = getPasswordStrength(password); + let bars = []; + let text = ""; + let textColor = ""; + + if (password.length === 0) { + bars = [`bg-[#F0F0F3]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; + text = "Password requirements"; + } else if (password.length < 8) { + bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; + text = "Password is too short"; + textColor = `text-[#DC3E42]`; + } else if (strength < 3) { + bars = [`bg-[#FFBA18]`, `bg-[#FFBA18]`, `bg-[#F0F0F3]`]; + text = "Password is weak"; + textColor = `text-[#FFBA18]`; + } else { + bars = [`bg-[#3E9B4F]`, `bg-[#3E9B4F]`, `bg-[#3E9B4F]`]; + text = "Password is strong"; + textColor = `text-[#3E9B4F]`; + } + + const criteria = [ + { label: "Min 8 characters", isValid: password.length >= 8 }, + { label: "Min 1 upper-case letter", isValid: /[A-Z]/.test(password) }, + { label: "Min 1 number", isValid: /\d/.test(password) }, + { label: "Min 1 special character", isValid: /[!@#$%^&*]/.test(password) }, + ]; + + return ( +
+
+ {bars.map((color, index) => ( +
+ ))} +
+

{text}

+
+ {criteria.map((criterion, index) => ( +
+ + {criterion.label} +
+ ))} +
+
+ ); +}; diff --git a/space/components/account/user-image-upload-modal.tsx b/space/components/account/user-image-upload-modal.tsx new file mode 100644 index 00000000000..072e2b480a1 --- /dev/null +++ b/space/components/account/user-image-upload-modal.tsx @@ -0,0 +1,187 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { useDropzone } from "react-dropzone"; +import { UserCircle2 } from "lucide-react"; +import { Transition, Dialog } from "@headlessui/react"; +// hooks +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// hooks +import { useMobxStore } from "@/lib/mobx/store-provider"; +// services +import fileService from "@/services/file.service"; + +type Props = { + handleDelete?: () => void; + isOpen: boolean; + isRemoving: boolean; + onClose: () => void; + onSuccess: (url: string) => void; + value: string | null; +}; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +export const UserImageUploadModal: React.FC = observer((props) => { + const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete } = props; + // states + const [image, setImage] = useState(null); + const [isImageUploading, setIsImageUploading] = useState(false); + // store hooks + const { + instanceStore: { instance }, + } = useMobxStore(); + + const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]); + + const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ + onDrop, + accept: { + "image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"], + }, + maxSize: instance?.config?.file_size_limit ?? MAX_FILE_SIZE, + multiple: false, + }); + + const handleClose = () => { + setImage(null); + setIsImageUploading(false); + onClose(); + }; + + const handleSubmit = async () => { + if (!image) return; + + setIsImageUploading(true); + + const formData = new FormData(); + formData.append("asset", image); + formData.append("attributes", JSON.stringify({})); + + fileService + .uploadUserFile(formData) + .then((res) => { + const imageUrl = res.asset; + + onSuccess(imageUrl); + setImage(null); + + if (value) fileService.deleteUserFile(value); + }) + .catch((err) => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ) + .finally(() => setIsImageUploading(false)); + }; + + return ( + + + +
+ + +
+
+ + +
+ + Upload Image + +
+
+
+ {image !== null || (value && value !== "") ? ( + <> + + image + + ) : ( +
+ + + {isDragActive ? "Drop image here to upload" : "Drag & drop image here"} + +
+ )} + + +
+
+ {fileRejections.length > 0 && ( +

+ {fileRejections[0].errors[0].code === "file-too-large" + ? "The image size cannot exceed 5 MB." + : "Please upload a file in a valid format."} +

+ )} +
+
+

+ File formats supported- .jpeg, .jpg, .png, .webp, .svg +

+
+ {handleDelete && ( + + )} +
+ + +
+
+
+
+
+
+
+
+ ); +}); diff --git a/space/hooks/use-auth-redirection.tsx b/space/hooks/use-auth-redirection.tsx new file mode 100644 index 00000000000..81b96287a56 --- /dev/null +++ b/space/hooks/use-auth-redirection.tsx @@ -0,0 +1,94 @@ +import { useCallback, useState } from "react"; +import { useRouter } from "next/router"; +// types +import { TUserProfile } from "@plane/types"; +// mobx store +import { useMobxStore } from "@/lib/mobx/store-provider"; + +type UseAuthRedirectionProps = { + error: any | null; + isRedirecting: boolean; + handleRedirection: () => Promise; +}; + +const useAuthRedirection = (): UseAuthRedirectionProps => { + // states + const [isRedirecting, setIsRedirecting] = useState(false); + const [error, setError] = useState(null); + // router + const router = useRouter(); + const { next_path } = router.query; + // mobx store + const { + profile: { fetchUserProfile }, + } = useMobxStore(); + + const isValidURL = (url: string): boolean => { + const disallowedSchemes = /^(https?|ftp):\/\//i; + return !disallowedSchemes.test(url); + }; + + const getAuthRedirectionUrl = useCallback( + async (profile: TUserProfile | undefined) => { + try { + if (!profile) return "/"; + + const isOnboard = profile.onboarding_step?.profile_complete; + + if (isOnboard) { + // if next_path is provided, redirect the user to that url + if (next_path) { + if (isValidURL(next_path.toString())) { + return next_path.toString(); + } else { + return "/"; + } + } + } else { + // if the user profile is not complete, redirect them to the onboarding page to complete their profile and then redirect them to the next path + if (next_path) return `/onboarding?next_path=${next_path}`; + else return "/onboarding"; + } + + return "/"; + } catch { + setIsRedirecting(false); + console.error("Error in handleSignInRedirection:", error); + setError(error); + } + }, + [next_path] + ); + + const updateUserProfileInfo = useCallback(async () => { + setIsRedirecting(true); + + await fetchUserProfile() + .then(async (profile) => { + if (profile) + await getAuthRedirectionUrl(profile) + .then((url: string | undefined) => { + if (url) { + router.push(url); + } + if (!url || url === "/") setIsRedirecting(false); + }) + .catch((err) => { + setError(err); + setIsRedirecting(false); + }); + }) + .catch((err) => { + setError(err); + setIsRedirecting(false); + }); + }, [fetchUserProfile, getAuthRedirectionUrl]); + + return { + error, + isRedirecting, + handleRedirection: updateUserProfileInfo, + }; +}; + +export default useAuthRedirection; diff --git a/space/layouts/instance-layout.tsx b/space/layouts/instance-layout.tsx new file mode 100644 index 00000000000..4841b926442 --- /dev/null +++ b/space/layouts/instance-layout.tsx @@ -0,0 +1,56 @@ +import { FC, ReactNode, useState } from "react"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +// ui +import { Spinner } from "@plane/ui"; +// components +import { InstanceNotReady } from "@/components/instance"; +// hooks +import { useMobxStore } from "@/lib/mobx/store-provider"; + +type TInstanceLayout = { + children: ReactNode; +}; + +const InstanceLayout: FC = observer((props) => { + const { children } = props; + // store + const { + instanceStore: { isLoading, instance, error, fetchInstanceInfo }, + } = useMobxStore(); + // states + const [isGodModeEnabled, setIsGodModeEnabled] = useState(false); + const handleGodModeStateChange = (state: boolean) => setIsGodModeEnabled(state); + + useSWR("INSTANCE_INFORMATION", () => fetchInstanceInfo(), { + revalidateOnFocus: false, + }); + + // loading state + if (isLoading) + return ( +
+ +
+ ); + + // something went wrong while in the request + if (error && error?.status === "error") + return ( +
+ Something went wrong. please try again later +
+ ); + + // checking if the instance is activated or not + if (error && !error?.data?.is_activated) return ; + + // instance is not ready and setup is not done + if (instance?.instance?.is_setup_done === false) + // if (isGodModeEnabled) return ; + return ; + + return <>{children}; +}); + +export default InstanceLayout; diff --git a/space/pages/accounts/forgot-password.tsx b/space/pages/accounts/forgot-password.tsx new file mode 100644 index 00000000000..d235ebcd442 --- /dev/null +++ b/space/pages/accounts/forgot-password.tsx @@ -0,0 +1,163 @@ +import { NextPage } from "next"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useTheme } from "next-themes"; +import { Controller, useForm } from "react-hook-form"; +// icons +import { CircleCheck } from "lucide-react"; +// ui +import { Button, Input, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { checkEmailValidity } from "@/helpers/string.helper"; +// hooks +// import useAuthRedirection from "@/hooks/use-auth-redirection"; +import useTimer from "@/hooks/use-timer"; +// services +import { AuthService } from "@/services/authentication.service"; +// images +import PlaneBackgroundPatternDark from "public/onboarding/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "public/onboarding/background-pattern.svg"; +import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; + +type TForgotPasswordFormValues = { + email: string; +}; + +const defaultValues: TForgotPasswordFormValues = { + email: "", +}; + +// services +const authService = new AuthService(); + +const ForgotPasswordPage: NextPage = () => { + // router + const router = useRouter(); + const { email } = router.query; + // hooks + const { resolvedTheme } = useTheme(); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); + + // form info + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + defaultValues: { + ...defaultValues, + email: email?.toString() ?? "", + }, + }); + + const handleForgotPassword = async (formData: TForgotPasswordFormValues) => { + await authService + .sendResetPasswordLink({ + email: formData.email, + }) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Email sent", + message: + "Check your inbox for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder.", + }); + setResendCodeTimer(30); + }) + .catch((err: any) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }); + }); + }; + + return ( +
+
+ Plane background pattern +
+
+
+
+ Plane Logo + Plane +
+
+
+
+
+
+

+ Reset your password +

+

+ Enter your user account{"'"}s verified email address and we will send you a password reset link. +

+
+
+
+ + checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( + 0} + /> + )} + /> + {resendTimerCode > 0 && ( +

+ + We sent the reset link to your email address +

+ )} +
+ + + Back to sign in + +
+
+
+
+
+
+ ); +}; + + +export default ForgotPasswordPage; diff --git a/space/pages/accounts/reset-password.tsx b/space/pages/accounts/reset-password.tsx new file mode 100644 index 00000000000..fe2d42340c7 --- /dev/null +++ b/space/pages/accounts/reset-password.tsx @@ -0,0 +1,200 @@ +import { useEffect, useMemo, useState } from "react"; +import { NextPage } from "next"; +import Image from "next/image"; +import { useRouter } from "next/router"; +// icons +import { useTheme } from "next-themes"; +import { Eye, EyeOff } from "lucide-react"; +// ui +import { Button, Input } from "@plane/ui"; +// components +import { PasswordStrengthMeter } from "@/components/accounts"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +import { getPasswordStrength } from "@/helpers/password.helper"; +// hooks +// services +import { AuthService } from "@/services/authentication.service"; +// images +import PlaneBackgroundPatternDark from "public/onboarding/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "public/onboarding/background-pattern.svg"; +import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; + +type TResetPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TResetPasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +const ResetPasswordPage: NextPage = () => { + // router + const router = useRouter(); + const { uidb64, token, email } = router.query; + // states + const [showPassword, setShowPassword] = useState(false); + const [resetFormData, setResetFormData] = useState({ + ...defaultValues, + email: email ? email.toString() : "", + }); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + // hooks + const { resolvedTheme } = useTheme(); + + useEffect(() => { + if (email && !resetFormData.email) { + setResetFormData((prev) => ({ ...prev, email: email.toString() })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [email]); + + const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) => + setResetFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const isButtonDisabled = useMemo( + () => + !!resetFormData.password && + getPasswordStrength(resetFormData.password) >= 3 && + resetFormData.password === resetFormData.confirm_password + ? false + : true, + [resetFormData] + ); + + return ( +
+
+ Plane background pattern +
+
+
+
+ Plane Logo + Plane +
+
+
+
+
+
+

+ Set new password +

+

Secure your account with a strong password

+
+
+ +
+ +
+ +
+
+
+ +
+ handleFormChange("password", e.target.value)} + //hasError={Boolean(errors.password)} + placeholder="Enter password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + minLength={8} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoFocus + /> + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
+ {isPasswordInputFocused && } +
+ {getPasswordStrength(resetFormData.password) >= 3 && ( +
+ +
+ handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + /> + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
+ {!!resetFormData.confirm_password && resetFormData.password !== resetFormData.confirm_password && ( + Password doesn{"'"}t match + )} +
+ )} + +
+
+
+
+
+
+ ); +}; + +export default ResetPasswordPage; diff --git a/space/public/onboarding/background-pattern-dark.svg b/space/public/onboarding/background-pattern-dark.svg new file mode 100644 index 00000000000..c258cbabf34 --- /dev/null +++ b/space/public/onboarding/background-pattern-dark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/space/public/onboarding/background-pattern.svg b/space/public/onboarding/background-pattern.svg new file mode 100644 index 00000000000..5fcbeec278a --- /dev/null +++ b/space/public/onboarding/background-pattern.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/space/store/profile.ts b/space/store/profile.ts new file mode 100644 index 00000000000..b6512eda147 --- /dev/null +++ b/space/store/profile.ts @@ -0,0 +1,127 @@ +import { action, makeObservable, observable, runInAction } from "mobx"; +// services +import { TUserProfile } from "@plane/types"; +import { UserService } from "services/user.service"; +// types +import { RootStore } from "./root"; + +type TError = { + status: string; + message: string; +}; + +export interface IProfileStore { + // observables + isLoading: boolean; + currentUserProfile: TUserProfile; + error: TError | undefined; + // actions + fetchUserProfile: () => Promise; + updateUserProfile: (currentUserProfile: Partial) => Promise; +} + +class ProfileStore implements IProfileStore { + isLoading: boolean = false; + currentUserProfile: TUserProfile = { + id: undefined, + user: undefined, + role: undefined, + last_workspace_id: undefined, + theme: { + theme: undefined, + text: undefined, + palette: undefined, + primary: undefined, + background: undefined, + darkPalette: undefined, + sidebarText: undefined, + sidebarBackground: undefined, + }, + onboarding_step: { + workspace_join: false, + profile_complete: false, + workspace_create: false, + workspace_invite: false, + }, + is_onboarded: false, + is_tour_completed: false, + use_case: undefined, + billing_address_country: undefined, + billing_address: undefined, + has_billing_address: false, + created_at: "", + updated_at: "", + }; + error: TError | undefined = undefined; + // root store + rootStore; + // services + userService: UserService; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + isLoading: observable.ref, + currentUserProfile: observable, + error: observable, + // actions + fetchUserProfile: action, + updateUserProfile: action, + }); + this.rootStore = _rootStore; + // services + this.userService = new UserService(); + } + + // actions + fetchUserProfile = async () => { + try { + runInAction(() => { + this.isLoading = true; + this.error = undefined; + }); + const userProfile = await this.userService.getCurrentUserProfile(); + runInAction(() => { + this.isLoading = false; + this.currentUserProfile = userProfile; + }); + + return userProfile; + } catch (error) { + console.log("Failed to fetch profile details"); + runInAction(() => { + this.isLoading = true; + this.error = { + status: "error", + message: "Failed to fetch instance info", + }; + }); + throw error; + } + }; + + updateUserProfile = async (currentUserProfile: Partial) => { + try { + runInAction(() => { + this.isLoading = true; + this.error = undefined; + }); + const userProfile = await this.userService.updateCurrentUserProfile(currentUserProfile); + runInAction(() => { + this.isLoading = false; + this.currentUserProfile = userProfile; + }); + } catch (error) { + console.log("Failed to fetch profile details"); + runInAction(() => { + this.isLoading = true; + this.error = { + status: "error", + message: "Failed to fetch instance info", + }; + }); + } + }; +} + +export default ProfileStore; diff --git a/turbo.json b/turbo.json index e980747df0a..942f34ea9dc 100644 --- a/turbo.json +++ b/turbo.json @@ -19,6 +19,7 @@ "NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS", "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST", + "NEXT_PUBLIC_GOD_MODE", "NEXT_PUBLIC_POSTHOG_DEBUG", "SENTRY_AUTH_TOKEN" ], diff --git a/web/components/account/auth-forms/index.ts b/web/components/account/auth-forms/index.ts index 604c4b5c606..c607000c704 100644 --- a/web/components/account/auth-forms/index.ts +++ b/web/components/account/auth-forms/index.ts @@ -1,9 +1,5 @@ -export * from "./auth-root"; - -export * from "./auth-header"; -export * from "./auth-banner"; - export * from "./email"; export * from "./forgot-password-popover"; export * from "./password"; +export * from "./root"; export * from "./unique-code"; diff --git a/web/components/account/auth-forms/root.tsx b/web/components/account/auth-forms/root.tsx new file mode 100644 index 00000000000..6ed70422ebc --- /dev/null +++ b/web/components/account/auth-forms/root.tsx @@ -0,0 +1,236 @@ +import React, { useEffect, useState } from "react"; +import isEmpty from "lodash/isEmpty"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +// types +import { IEmailCheckData, IWorkspaceMemberInvitation } from "@plane/types"; +// ui +import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { + AuthEmailForm, + AuthPasswordForm, + OAuthOptions, + TermsAndConditions, + UniqueCodeForm, +} from "@/components/account"; +import { WorkspaceLogo } from "@/components/workspace/logo"; +import { useInstance } from "@/hooks/store"; +// services +import { AuthService } from "@/services/auth.service"; +import { WorkspaceService } from "@/services/workspace.service"; + +const authService = new AuthService(); +const workSpaceService = new WorkspaceService(); + +export enum EAuthSteps { + EMAIL = "EMAIL", + PASSWORD = "PASSWORD", + UNIQUE_CODE = "UNIQUE_CODE", + OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD", +} + +export enum EAuthModes { + SIGN_IN = "SIGN_IN", + SIGN_UP = "SIGN_UP", +} + +type Props = { + mode: EAuthModes; +}; + +const Titles = { + [EAuthModes.SIGN_IN]: { + [EAuthSteps.EMAIL]: { + header: "Sign in to Plane", + subHeader: "Get back to your projects and make progress", + }, + [EAuthSteps.PASSWORD]: { + header: "Sign in to Plane", + subHeader: "Get back to your projects and make progress", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Sign in to Plane", + subHeader: "Get back to your projects and make progress", + }, + [EAuthSteps.OPTIONAL_SET_PASSWORD]: { + header: "", + subHeader: "", + }, + }, + [EAuthModes.SIGN_UP]: { + [EAuthSteps.EMAIL]: { + header: "Create your account", + subHeader: "Start tracking your projects with Plane", + }, + [EAuthSteps.PASSWORD]: { + header: "Create your account", + subHeader: "Progress, visualize, and measure work how it works best for you.", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Create your account", + subHeader: "Progress, visualize, and measure work how it works best for you.", + }, + [EAuthSteps.OPTIONAL_SET_PASSWORD]: { + header: "", + subHeader: "", + }, + }, +}; + +const getHeaderSubHeader = ( + step: EAuthSteps, + mode: EAuthModes, + invitation?: IWorkspaceMemberInvitation | undefined, + email?: string +) => { + if (invitation && email && invitation.email === email && invitation.workspace) { + const workspace = invitation.workspace; + return { + header: ( + <> + Join {workspace.name} + + ), + subHeader: `${ + mode == EAuthModes.SIGN_UP ? "Create an account" : "Sign in" + } to start managing work with your team.`, + }; + } + + return Titles[mode][step]; +}; + +export const AuthRoot = observer((props: Props) => { + const { mode } = props; + //router + const router = useRouter(); + const { email: emailParam, invitation_id, slug } = router.query; + // states + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); + const [invitation, setInvitation] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + // hooks + const { instance } = useInstance(); + // derived values + const isSmtpConfigured = instance?.config?.is_smtp_configured; + + const redirectToSignUp = (email: string) => { + if (isEmpty(email)) router.push({ pathname: "/", query: router.query }); + else router.push({ pathname: "/", query: { ...router.query, email: email } }); + }; + + const redirectToSignIn = (email: string) => { + if (isEmpty(email)) router.push({ pathname: "/accounts/sign-in", query: router.query }); + else router.push({ pathname: "/accounts/sign-in", query: { ...router.query, email: email } }); + }; + + useEffect(() => { + if (invitation_id && slug) { + setIsLoading(true); + workSpaceService + .getWorkspaceInvitation(slug.toString(), invitation_id.toString()) + .then((res) => { + setInvitation(res); + }) + .catch(() => { + setInvitation(undefined); + }) + .finally(() => setIsLoading(false)); + } else { + setInvitation(undefined); + } + }, [invitation_id, slug]); + + const { header, subHeader } = getHeaderSubHeader(authStep, mode, invitation, email); + + // step 1 submit handler- email verification + const handleEmailVerification = async (data: IEmailCheckData) => { + setEmail(data.email); + + const emailCheck = mode === EAuthModes.SIGN_UP ? authService.signUpEmailCheck : authService.signInEmailCheck; + + await emailCheck(data) + .then((res) => { + if (mode === EAuthModes.SIGN_IN && !res.is_password_autoset) { + setAuthStep(EAuthSteps.PASSWORD); + } else { + if (isSmtpConfigured) { + setAuthStep(EAuthSteps.UNIQUE_CODE); + } else { + if (mode === EAuthModes.SIGN_IN) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Unable to process request please contact Administrator to reset password", + }); + } else { + setAuthStep(EAuthSteps.PASSWORD); + } + } + } + }) + .catch((err) => { + if (err?.error_code === "USER_DOES_NOT_EXIST") { + redirectToSignUp(data.email); + return; + } else if (err?.error_code === "USER_ALREADY_EXIST") { + redirectToSignIn(data.email); + return; + } + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.error_message ?? "Something went wrong. Please try again.", + }); + }); + }; + + const isOAuthEnabled = + instance?.config && (instance?.config?.is_google_enabled || instance?.config?.is_github_enabled); + + if (isLoading) + return ( +
+ +
+ ); + + return ( + <> +
+
+

{header}

+

{subHeader}

+
+ {authStep === EAuthSteps.EMAIL && } + {authStep === EAuthSteps.UNIQUE_CODE && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + submitButtonText="Continue" + mode={mode} + /> + )} + {authStep === EAuthSteps.PASSWORD && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + handleStepChange={(step) => setAuthStep(step)} + mode={mode} + /> + )} +
+ {isOAuthEnabled && authStep !== EAuthSteps.OPTIONAL_SET_PASSWORD && } + + + + ); +}); diff --git a/web/components/api-token/delete-token-modal.tsx b/web/components/api-token/delete-token-modal.tsx index 014531c4240..fd0aaa7d7b7 100644 --- a/web/components/api-token/delete-token-modal.tsx +++ b/web/components/api-token/delete-token-modal.tsx @@ -7,8 +7,11 @@ import { IApiToken } from "@plane/types"; import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { AlertModalCore } from "@/components/core"; -// fetch-keys +// constants +import { API_TOKEN_DELETED } from "@/constants/event-tracker"; import { API_TOKENS_LIST } from "@/constants/fetch-keys"; +// hooks +import { useEventTracker } from "@/hooks/store"; // services import { APITokenService } from "@/services/api_token.service"; @@ -27,6 +30,8 @@ export const DeleteApiTokenModal: FC = (props) => { // router const router = useRouter(); const { workspaceSlug } = router.query; + // store hooks + const { captureEvent } = useEventTracker(); const handleClose = () => { onClose(); @@ -46,6 +51,9 @@ export const DeleteApiTokenModal: FC = (props) => { title: "Success!", message: "Token deleted successfully.", }); + captureEvent(API_TOKEN_DELETED, { + token_id: tokenId, + }); mutate( API_TOKENS_LIST(workspaceSlug.toString()), diff --git a/web/components/api-token/modal/create-token-modal.tsx b/web/components/api-token/modal/create-token-modal.tsx index f71b5fcdd52..342783f1c0d 100644 --- a/web/components/api-token/modal/create-token-modal.tsx +++ b/web/components/api-token/modal/create-token-modal.tsx @@ -8,11 +8,14 @@ import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { CreateApiTokenForm, GeneratedTokenDetails } from "@/components/api-token"; import { EModalPosition, EModalWidth, ModalCore } from "@/components/core"; -// fetch-keys +// constants +import { API_TOKEN_CREATED } from "@/constants/event-tracker"; import { API_TOKENS_LIST } from "@/constants/fetch-keys"; // helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; import { csvDownload } from "@/helpers/download.helper"; +// hooks +import { useEventTracker } from "@/hooks/store"; // services import { APITokenService } from "@/services/api_token.service"; @@ -32,6 +35,8 @@ export const CreateApiTokenModal: React.FC = (props) => { // router const router = useRouter(); const { workspaceSlug } = router.query; + // store hooks + const { captureEvent } = useEventTracker(); const handleClose = () => { onClose(); @@ -72,6 +77,11 @@ export const CreateApiTokenModal: React.FC = (props) => { }, false ); + captureEvent(API_TOKEN_CREATED, { + token_id: res.id, + expiry_date: data.expired_at ?? undefined, + never_exprires: res.expired_at ? false : true, + }); }) .catch((err) => { setToast({ diff --git a/web/components/automation/auto-archive-automation.tsx b/web/components/automation/auto-archive-automation.tsx index 68e3d2938af..ba7a250b509 100644 --- a/web/components/automation/auto-archive-automation.tsx +++ b/web/components/automation/auto-archive-automation.tsx @@ -8,8 +8,9 @@ import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui"; import { SelectMonthModal } from "@/components/automation"; // icon // constants +import { AUTO_ARCHIVE_TOGGLED, AUTO_ARCHIVE_UPDATED } from "@/constants/event-tracker"; import { EUserProjectRoles, PROJECT_AUTOMATION_MONTHS } from "@/constants/project"; -import { useProject, useUser } from "@/hooks/store"; +import { useEventTracker, useProject, useUser } from "@/hooks/store"; // types type Props = { @@ -27,6 +28,7 @@ export const AutoArchiveAutomation: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails } = useProject(); + const { captureEvent } = useEventTracker(); const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; @@ -54,11 +56,16 @@ export const AutoArchiveAutomation: React.FC = observer((props) => {
+ onChange={() => { currentProjectDetails?.archive_in === 0 ? handleChange({ archive_in: 1 }) - : handleChange({ archive_in: 0 }) - } + : handleChange({ archive_in: 0 }); + + captureEvent(AUTO_ARCHIVE_TOGGLED, { + toggle: currentProjectDetails?.archive_in === 0 ? "true" : "false", + range: `${currentProjectDetails?.archive_in == 0 ? 1 : 0} month`, + }); + }} size="sm" disabled={!isAdmin} /> @@ -76,6 +83,9 @@ export const AutoArchiveAutomation: React.FC = observer((props) => { currentProjectDetails?.archive_in === 1 ? "month" : "months" }`} onChange={(val: number) => { + captureEvent(AUTO_ARCHIVE_UPDATED, { + range: val === 1 ? "1 month" : `${val} months`, + }); handleChange({ archive_in: val }); }} input diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index 875f37f12a0..b97d7aa94fe 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -5,12 +5,13 @@ import { ArchiveX } from "lucide-react"; import { IProject } from "@plane/types"; import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleCircleIcon, Loader } from "@plane/ui"; import { SelectMonthModal } from "@/components/automation"; +// constants +import { AUTO_CLOSE_Toggled, AUTO_CLOSE_UPDATED } from "@/constants/event-tracker"; import { EUserProjectRoles, PROJECT_AUTOMATION_MONTHS } from "@/constants/project"; -import { useProject, useProjectState, useUser } from "@/hooks/store"; +import { useEventTracker, useProject, useProjectState, useUser } from "@/hooks/store"; // component // icons // types -// constants type Props = { handleChange: (formData: Partial) => Promise; @@ -26,6 +27,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { } = useUser(); const { currentProjectDetails } = useProject(); const { projectStates } = useProjectState(); + const { captureEvent } = useEventTracker(); // const stateGroups = projectStateStore.groupedProjectStates ?? undefined; @@ -80,11 +82,17 @@ export const AutoCloseAutomation: React.FC = observer((props) => { + onChange={() => { currentProjectDetails?.close_in === 0 ? handleChange({ close_in: 1, default_state: defaultState }) - : handleChange({ close_in: 0, default_state: null }) - } + : handleChange({ close_in: 0, default_state: null }); + + captureEvent(AUTO_CLOSE_Toggled, { + toggle: currentProjectDetails?.close_in === 0 ? "true" : "false", + range: `${currentProjectDetails?.close_in == 0 ? 1 : 0} month`, + state: currentProjectDetails?.close_in === 0 ? undefined : defaultState, + }); + }} size="sm" disabled={!isAdmin} /> @@ -104,6 +112,10 @@ export const AutoCloseAutomation: React.FC = observer((props) => { }`} onChange={(val: number) => { handleChange({ close_in: val }); + captureEvent(AUTO_CLOSE_UPDATED, { + range: val === 1 ? "1 month" : `${val} months`, + state: currentProjectDetails?.default_state, + }); }} input disabled={!isAdmin} diff --git a/web/components/command-palette/actions/project-actions.tsx b/web/components/command-palette/actions/project-actions.tsx index 32e7ed04596..c87bf007cf0 100644 --- a/web/components/command-palette/actions/project-actions.tsx +++ b/web/components/command-palette/actions/project-actions.tsx @@ -1,7 +1,9 @@ import { Command } from "cmdk"; import { ContrastIcon, FileText } from "lucide-react"; -// hooks import { DiceIcon, PhotoFilterIcon } from "@plane/ui"; +// constants +import { E_COMMAND_PALETTE } from "@/constants/event-tracker"; +// hooks import { useCommandPalette, useEventTracker } from "@/hooks/store"; // ui @@ -22,7 +24,7 @@ export const CommandPaletteProjectActions: React.FC = (props) => { { closePalette(); - setTrackElement("Command palette"); + setTrackElement(E_COMMAND_PALETTE); toggleCreateCycleModal(true); }} className="focus:outline-none" @@ -38,7 +40,7 @@ export const CommandPaletteProjectActions: React.FC = (props) => { { closePalette(); - setTrackElement("Command palette"); + setTrackElement(E_COMMAND_PALETTE); toggleCreateModuleModal(true); }} className="focus:outline-none" @@ -54,7 +56,7 @@ export const CommandPaletteProjectActions: React.FC = (props) => { { closePalette(); - setTrackElement("Command palette"); + setTrackElement(E_COMMAND_PALETTE); toggleCreateViewModal(true); }} className="focus:outline-none" @@ -70,7 +72,7 @@ export const CommandPaletteProjectActions: React.FC = (props) => { { closePalette(); - setTrackElement("Command palette"); + setTrackElement(E_COMMAND_PALETTE); toggleCreatePageModal(true); }} className="focus:outline-none" diff --git a/web/components/command-palette/actions/theme-actions.tsx b/web/components/command-palette/actions/theme-actions.tsx index 7b0ddc72bc3..07837678d0c 100644 --- a/web/components/command-palette/actions/theme-actions.tsx +++ b/web/components/command-palette/actions/theme-actions.tsx @@ -2,6 +2,7 @@ import React, { FC, useEffect, useState } from "react"; import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; +// icons import { Settings } from "lucide-react"; import { TOAST_TYPE, setToast } from "@plane/ui"; // constants diff --git a/web/components/command-palette/command-modal.tsx b/web/components/command-palette/command-modal.tsx index 6c05c9b1743..8a88e5aa355 100644 --- a/web/components/command-palette/command-modal.tsx +++ b/web/components/command-palette/command-modal.tsx @@ -21,7 +21,9 @@ import { CommandPaletteSearchResults, } from "@/components/command-palette"; import { EmptyState } from "@/components/empty-state"; +// constants import { EmptyStateType } from "@/constants/empty-state"; +import { E_COMMAND_PALETTE } from "@/constants/event-tracker"; import { ISSUE_DETAILS } from "@/constants/fetch-keys"; import { useCommandPalette, useEventTracker, useProject } from "@/hooks/store"; import useDebounce from "@/hooks/use-debounce"; @@ -33,7 +35,6 @@ import { WorkspaceService } from "@/services/workspace.service"; // components // types // fetch-keys -// constants const workspaceService = new WorkspaceService(); const issueService = new IssueService(); @@ -282,7 +283,7 @@ export const CommandModal: React.FC = observer(() => { { closePalette(); - setTrackElement("Command Palette"); + setTrackElement(E_COMMAND_PALETTE); toggleCreateIssueModal(true); }} className="focus:bg-custom-background-80" @@ -301,7 +302,7 @@ export const CommandModal: React.FC = observer(() => { { closePalette(); - setTrackElement("Command palette"); + setTrackElement(E_COMMAND_PALETTE); toggleCreateProjectModal(true); }} className="focus:outline-none" diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index 9143d44c77c..53c9308e3b0 100644 --- a/web/components/command-palette/command-palette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -14,6 +14,7 @@ import { CreatePageModal } from "@/components/pages"; import { CreateProjectModal } from "@/components/project"; import { CreateUpdateProjectViewModal } from "@/components/views"; // constants +import { E_SHORTCUT_KEY } from "@/constants/event-tracker"; import { ISSUE_DETAILS } from "@/constants/fetch-keys"; import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; @@ -220,7 +221,7 @@ export const CommandPalette: FC = observer(() => { toggleSidebar(); } } else if (!isAnyModalOpen) { - setTrackElement("Shortcut key"); + setTrackElement(E_SHORTCUT_KEY); if (Object.keys(shortcutsList.global).includes(keyPressed)) shortcutsList.global[keyPressed].action(); // workspace authorized actions else if ( diff --git a/web/components/core/activity.tsx b/web/components/core/activity.tsx index 97927d216af..9fe29057678 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -21,16 +21,20 @@ import { } from "lucide-react"; import { IIssueActivity } from "@plane/types"; import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } from "@plane/ui"; +// constants +import { ISSUE_OPENED, elementFromPath } from "@/constants/event-tracker"; // helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; import { capitalizeFirstLetter } from "@/helpers/string.helper"; -import { useEstimate, useLabel } from "@/hooks/store"; +import { useEstimate, useLabel, useEventTracker } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // types export const IssueLink = ({ activity }: { activity: IIssueActivity }) => { const router = useRouter(); const { workspaceSlug } = router.query; + // store hooks + const { captureEvent } = useEventTracker(); const { isMobile } = usePlatformOS(); return ( @@ -44,6 +48,13 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => { href={`${`/${workspaceSlug ?? activity.workspace_detail?.slug}/projects/${activity.project}/issues/${ activity.issue }`}`} + onClick={() => { + captureEvent(ISSUE_OPENED, { + ...elementFromPath(router.asPath), + element_id: "activity", + mode: "detail", + }); + }} target={activity.issue === null ? "_self" : "_blank"} rel={activity.issue === null ? "" : "noopener noreferrer"} className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline" diff --git a/web/components/core/modals/gpt-assistant-popover.tsx b/web/components/core/modals/gpt-assistant-popover.tsx index a67b13f7ef9..8b0747dd7df 100644 --- a/web/components/core/modals/gpt-assistant-popover.tsx +++ b/web/components/core/modals/gpt-assistant-popover.tsx @@ -17,7 +17,9 @@ type Props = { isOpen: boolean; projectId: string; handleClose: () => void; - onResponse: (response: any) => void; + onResponse: (query: string, response: any) => void; + onGenerateResponse?: (query: string, response: any) => void; + onReGenerateResponse?: (query: string, response: any) => void; onError?: (error: any) => void; placement?: Placement; prompt?: string; @@ -33,7 +35,19 @@ type FormData = { const aiService = new AIService(); export const GptAssistantPopover: React.FC = (props) => { - const { isOpen, projectId, handleClose, onResponse, onError, placement, prompt, button, className = "" } = props; + const { + isOpen, + projectId, + handleClose, + onResponse, + onGenerateResponse, + onReGenerateResponse, + onError, + placement, + prompt, + button, + className = "", + } = props; // states const [response, setResponse] = useState(""); const [invalidResponse, setInvalidResponse] = useState(false); @@ -53,6 +67,7 @@ export const GptAssistantPopover: React.FC = (props) => { handleSubmit, control, reset, + getValues, setFocus, formState: { isSubmitting }, } = useForm({ @@ -118,6 +133,8 @@ export const GptAssistantPopover: React.FC = (props) => { } await callAIService(formData); + if (response !== "" && onReGenerateResponse) onReGenerateResponse(formData.task, response); + else if (response === "" && onGenerateResponse) onGenerateResponse(formData.task, response); }; useEffect(() => { @@ -162,7 +179,7 @@ export const GptAssistantPopover: React.FC = (props) => { diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 95983d85a37..8dce739c8cb 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -13,6 +13,17 @@ import { BreadcrumbLink } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; import { ProjectLogo } from "@/components/project"; // constants +import { + DP_APPLIED, + DP_REMOVED, + E_PROJECT_ISSUES, + elementFromPath, + FILTER_APPLIED, + FILTER_REMOVED, + FILTER_SEARCHED, + LAYOUT_CHANGED, + LP_UPDATED, +} from "@/constants/event-tracker"; import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers @@ -44,8 +55,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.PROJECT); + const { captureEvent, setTrackElement, captureIssuesFilterEvent, captureIssuesDisplayFilterEvent } = + useEventTracker(); const { toggleCreateIssueModal } = useCommandPalette(); - const { setTrackElement } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -58,7 +70,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; + const newValues = Array.from(issueFilters?.filters?.[key] ?? []); if (Array.isArray(value)) { // this validation is majorly for the filter start_date, target_date custom @@ -71,33 +83,68 @@ export const ProjectIssuesHeader: React.FC = observer(() => { else newValues.push(value); } - updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); + const event = (issueFilters?.filters?.[key] ?? []).length > newValues.length ? FILTER_REMOVED : FILTER_APPLIED; + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }).then(() => + captureIssuesFilterEvent({ + eventName: event, + payload: { + routePath: router.asPath, + filters: issueFilters, + filter_property: value, + filter_type: key, + }, + }) + ); }, - [workspaceSlug, projectId, issueFilters, updateFilters] + [workspaceSlug, projectId, issueFilters, updateFilters, captureIssuesFilterEvent, router.asPath] ); const handleLayoutChange = useCallback( (layout: TIssueLayouts) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }).then(() => + captureEvent(LAYOUT_CHANGED, { + layout: layout, + ...elementFromPath(router.asPath), + }) + ); }, - [workspaceSlug, projectId, updateFilters] + [workspaceSlug, projectId, updateFilters, captureEvent, router.asPath] ); const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter).then(() => + captureIssuesDisplayFilterEvent({ + eventName: LP_UPDATED, + payload: { + property_type: Object.keys(updatedDisplayFilter).join(","), + property: Object.values(updatedDisplayFilter)?.[0], + routePath: router.asPath, + filters: issueFilters, + }, + }) + ); }, - [workspaceSlug, projectId, updateFilters] + [workspaceSlug, projectId, updateFilters, issueFilters, captureIssuesDisplayFilterEvent, router.asPath] ); const handleDisplayProperties = useCallback( (property: Partial) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property).then(() => { + captureIssuesDisplayFilterEvent({ + eventName: Object.values(property)?.[0] === true ? DP_APPLIED : DP_REMOVED, + payload: { + display_property: Object.keys(property).join(","), + routePath: router.asPath, + filters: issueFilters, + }, + }); + }); }, - [workspaceSlug, projectId, updateFilters] + [workspaceSlug, projectId, updateFilters, issueFilters, captureIssuesDisplayFilterEvent, router.asPath] ); const DEPLOY_URL = SPACE_BASE_URL + SPACE_BASE_PATH; @@ -196,6 +243,16 @@ export const ProjectIssuesHeader: React.FC = observer(() => { states={projectStates} cycleViewDisabled={!currentProjectDetails?.cycle_view} moduleViewDisabled={!currentProjectDetails?.module_view} + onSearchCapture={() => + captureIssuesFilterEvent({ + eventName: FILTER_SEARCHED, + payload: { + routePath: router.asPath, + current_filters: issueFilters?.filters, + layout: issueFilters?.displayFilters?.layout, + }, + }) + } /> @@ -225,7 +282,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { diff --git a/web/components/headers/projects.tsx b/web/components/headers/projects.tsx index 7126b2697af..1d05517f1aa 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -10,6 +10,7 @@ import { BreadcrumbLink } from "@/components/common"; import { FiltersDropdown } from "@/components/issues"; import { ProjectFiltersSelection, ProjectOrderByDropdown } from "@/components/project"; // constants +import { E_PROJECTS } from "@/constants/event-tracker"; import { EUserWorkspaceRoles } from "@/constants/workspace"; // helpers import { cn } from "@/helpers/common.helper"; @@ -169,7 +170,7 @@ export const ProjectsHeader = observer(() => {