diff --git a/README.md b/README.md index f6b364befe..5a466594f8 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Getting started with Plane is simple. Choose the setup that works best for you: ## 🌟 Features -- **Issues** +- **Work Items** Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues. - **Cycles** @@ -72,15 +72,13 @@ Getting started with Plane is simple. Choose the setup that works best for you: - **Analytics** Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward. -- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution. - ## 🛠️ Local development See [CONTRIBUTING](./CONTRIBUTING.md) ## ⚙️ Built with -[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/) +[![React Router](https://img.shields.io/badge/-React%20Router-CA4245?logo=react-router&style=for-the-badge&logoColor=white)](https://reactrouter.com/) [![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/) [![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en) diff --git a/apps/admin/app/(all)/(dashboard)/ai/form.tsx b/apps/admin/app/(all)/(dashboard)/ai/form.tsx index 3cb96a454c..69456d8b61 100644 --- a/apps/admin/app/(all)/(dashboard)/ai/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/ai/form.tsx @@ -114,13 +114,13 @@ export function InstanceAIForm(props: IInstanceAIForm) { -
+
-
- +
+
If you have a preferred AI models vendor, please get in{" "} diff --git a/apps/admin/app/(all)/(dashboard)/ai/page.tsx b/apps/admin/app/(all)/(dashboard)/ai/page.tsx index 04a440fcf4..bf290ef361 100644 --- a/apps/admin/app/(all)/(dashboard)/ai/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/ai/page.tsx @@ -1,10 +1,13 @@ import { observer } from "mobx-react"; import useSWR from "swr"; import { Loader } from "@plane/ui"; +// components +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// components +// types import type { Route } from "./+types/page"; +// local import { InstanceAIForm } from "./form"; const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentProps) { @@ -14,30 +17,25 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); return ( - <> -
-
-
AI features for all your workspaces
-
- Configure your AI API credentials so Plane AI features are turned on for all your workspaces. + + {formattedConfig ? ( + + ) : ( + + +
+ +
-
-
- {formattedConfig ? ( - - ) : ( - - -
- - -
- -
- )} -
-
- + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx index 250c451250..c2e637e279 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx @@ -12,6 +12,8 @@ import { CodeBlock } from "@/components/common/code-block"; import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; import type { TControllerInputFormField } from "@/components/common/controller-input"; import { ControllerInput } from "@/components/common/controller-input"; +import type { TControllerSwitchFormField } from "@/components/common/controller-switch"; +import { ControllerSwitch } from "@/components/common/controller-switch"; import type { TCopyField } from "@/components/common/copy-field"; import { CopyField } from "@/components/common/copy-field"; // hooks @@ -40,6 +42,7 @@ export function InstanceGiteaConfigForm(props: Props) { GITEA_HOST: config["GITEA_HOST"] || "https://gitea.com", GITEA_CLIENT_ID: config["GITEA_CLIENT_ID"], GITEA_CLIENT_SECRET: config["GITEA_CLIENT_SECRET"], + ENABLE_GITEA_SYNC: config["ENABLE_GITEA_SYNC"] || "0", }, }); @@ -103,6 +106,11 @@ export function InstanceGiteaConfigForm(props: Props) { }, ]; + const GITEA_FORM_SWITCH_FIELD: TControllerSwitchFormField = { + name: "ENABLE_GITEA_SYNC", + label: "Gitea", + }; + const GITEA_SERVICE_FIELD: TCopyField[] = [ { key: "Callback_URI", @@ -129,20 +137,22 @@ export function InstanceGiteaConfigForm(props: Props) { const onSubmit = async (formData: GiteaConfigFormValues) => { const payload: Partial = { ...formData }; - await updateInstanceConfigurations(payload) - .then((response = []) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Done!", - message: "Your Gitea authentication is configured. You should test it now.", - }); - reset({ - GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value, - GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value, - GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value, - }); - }) - .catch((err) => console.error(err)); + try { + const response = await updateInstanceConfigurations(payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your Gitea authentication is configured. You should test it now.", + }); + reset({ + GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value, + GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value, + GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value, + ENABLE_GITEA_SYNC: response.find((item) => item.key === "ENABLE_GITEA_SYNC")?.value, + }); + } catch (err) { + console.error(err); + } }; const handleGoBack = (e: React.MouseEvent) => { @@ -176,16 +186,17 @@ export function InstanceGiteaConfigForm(props: Props) { required={field.required} /> ))} +
Go back @@ -194,7 +205,7 @@ export function InstanceGiteaConfigForm(props: Props) {
-
+
Plane-provided details for Gitea
{GITEA_SERVICE_FIELD.map((field) => ( diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx index 6dd90a2d18..f0f7e23338 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx @@ -4,13 +4,16 @@ import useSWR from "swr"; // plane internal packages import { setPromiseToast } from "@plane/propel/toast"; import { Loader, ToggleSwitch } from "@plane/ui"; -// components +// assets import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; +// components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -//local components +// types import type { Route } from "./+types/page"; +// local import { InstanceGiteaConfigForm } from "./form"; const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthenticationPage() { @@ -32,7 +35,7 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic const updateConfigPromise = updateInstanceConfigurations(payload); setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", + loading: "Saving Configuration", success: { title: "Configuration saved", message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`, @@ -56,42 +59,39 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic const isGiteaEnabled = enableGiteaConfig === "1"; return ( - <> -
-
- } - config={ - { - updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1"); - }} - size="sm" - disabled={isSubmitting || !formattedConfig} - /> - } - disabled={isSubmitting || !formattedConfig} - withBorder={false} - /> -
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- + } + config={ + { + updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> + } + > + {formattedConfig ? ( + + ) : ( + + + + + + + + )} + ); }); export const meta: Route.MetaFunction = () => [{ title: "Gitea Authentication - God Mode" }]; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx index 56c8343013..c1aef0f6cc 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx @@ -8,12 +8,12 @@ import { API_BASE_URL } from "@plane/constants"; import { Button, getButtonStyling } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; - -import { cn } from "@plane/utils"; // components import { CodeBlock } from "@/components/common/code-block"; import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; import type { TControllerInputFormField } from "@/components/common/controller-input"; +import type { TControllerSwitchFormField } from "@/components/common/controller-switch"; +import { ControllerSwitch } from "@/components/common/controller-switch"; import { ControllerInput } from "@/components/common/controller-input"; import type { TCopyField } from "@/components/common/copy-field"; import { CopyField } from "@/components/common/copy-field"; @@ -43,6 +43,7 @@ export function InstanceGithubConfigForm(props: Props) { GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"], GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"], GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"], + ENABLE_GITHUB_SYNC: config["ENABLE_GITHUB_SYNC"] || "0", }, }); @@ -104,6 +105,11 @@ export function InstanceGithubConfigForm(props: Props) { }, ]; + const GITHUB_FORM_SWITCH_FIELD: TControllerSwitchFormField = { + name: "ENABLE_GITHUB_SYNC", + label: "GitHub", + }; + const GITHUB_COMMON_SERVICE_DETAILS: TCopyField[] = [ { key: "Origin_URL", @@ -152,20 +158,22 @@ export function InstanceGithubConfigForm(props: Props) { const onSubmit = async (formData: GithubConfigFormValues) => { const payload: Partial = { ...formData }; - await updateInstanceConfigurations(payload) - .then((response = []) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Done!", - message: "Your GitHub authentication is configured. You should test it now.", - }); - reset({ - GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, - GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value, - GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value, - }); - }) - .catch((err) => console.error(err)); + try { + const response = await updateInstanceConfigurations(payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your GitHub authentication is configured. You should test it now.", + }); + reset({ + GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, + GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value, + GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value, + ENABLE_GITHUB_SYNC: response.find((item) => item.key === "ENABLE_GITHUB_SYNC")?.value, + }); + } catch (err) { + console.error(err); + } }; const handleGoBack = (e: React.MouseEvent) => { @@ -199,16 +207,17 @@ export function InstanceGithubConfigForm(props: Props) { required={field.required} /> ))} +
Go back @@ -229,7 +238,7 @@ export function InstanceGithubConfigForm(props: Props) { {/* web service details */}
-
+
Web
diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx index 438bfc9f94..1186332d37 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx @@ -6,15 +6,17 @@ import useSWR from "swr"; import { setPromiseToast } from "@plane/propel/toast"; import { Loader, ToggleSwitch } from "@plane/ui"; import { resolveGeneralTheme } from "@plane/utils"; -// components +// assets import githubLightModeImage from "@/app/assets/logos/github-black.png?url"; import githubDarkModeImage from "@/app/assets/logos/github-white.png?url"; +// components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// icons -// local components +// types import type { Route } from "./+types/page"; +// local import { InstanceGithubConfigForm } from "./form"; const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthenticationPage( @@ -41,7 +43,7 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent const updateConfigPromise = updateInstanceConfigurations(payload); setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", + loading: "Saving Configuration", success: { title: "Configuration saved", message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`, @@ -65,49 +67,46 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent const isGithubEnabled = enableGithubConfig === "1"; return ( - <> -
-
- - } - config={ - { - updateConfig("IS_GITHUB_ENABLED", isGithubEnabled ? "0" : "1"); - }} - size="sm" - disabled={isSubmitting || !formattedConfig} - /> - } - disabled={isSubmitting || !formattedConfig} - withBorder={false} - /> -
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- + + } + config={ + { + updateConfig("IS_GITHUB_ENABLED", isGithubEnabled ? "0" : "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> + } + > + {formattedConfig ? ( + + ) : ( + + + + + + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx index 1f6c8ba4f3..4511a70c91 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx @@ -7,11 +7,12 @@ import { API_BASE_URL } from "@plane/constants"; import { Button, getButtonStyling } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types"; -import { cn } from "@plane/utils"; // components import { CodeBlock } from "@/components/common/code-block"; import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; import type { TControllerInputFormField } from "@/components/common/controller-input"; +import type { TControllerSwitchFormField } from "@/components/common/controller-switch"; +import { ControllerSwitch } from "@/components/common/controller-switch"; import { ControllerInput } from "@/components/common/controller-input"; import type { TCopyField } from "@/components/common/copy-field"; import { CopyField } from "@/components/common/copy-field"; @@ -41,6 +42,7 @@ export function InstanceGitlabConfigForm(props: Props) { GITLAB_HOST: config["GITLAB_HOST"], GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"], GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"], + ENABLE_GITLAB_SYNC: config["ENABLE_GITLAB_SYNC"] || "0", }, }); @@ -108,6 +110,11 @@ export function InstanceGitlabConfigForm(props: Props) { }, ]; + const GITLAB_FORM_SWITCH_FIELD: TControllerSwitchFormField = { + name: "ENABLE_GITLAB_SYNC", + label: "GitLab", + }; + const GITLAB_SERVICE_FIELD: TCopyField[] = [ { key: "Callback_URL", @@ -134,20 +141,22 @@ export function InstanceGitlabConfigForm(props: Props) { const onSubmit = async (formData: GitlabConfigFormValues) => { const payload: Partial = { ...formData }; - await updateInstanceConfigurations(payload) - .then((response = []) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Done!", - message: "Your GitLab authentication is configured. You should test it now.", - }); - reset({ - GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value, - GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value, - GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value, - }); - }) - .catch((err) => console.error(err)); + try { + const response = await updateInstanceConfigurations(payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your GitLab authentication is configured. You should test it now.", + }); + reset({ + GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value, + GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value, + GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value, + ENABLE_GITLAB_SYNC: response.find((item) => item.key === "ENABLE_GITLAB_SYNC")?.value, + }); + } catch (err) { + console.error(err); + } }; const handleGoBack = (e: React.MouseEvent) => { @@ -181,16 +190,17 @@ export function InstanceGitlabConfigForm(props: Props) { required={field.required} /> ))} +
Go back @@ -199,7 +209,7 @@ export function InstanceGitlabConfigForm(props: Props) {
-
+
Plane-provided details for GitLab
{GITLAB_SERVICE_FIELD.map((field) => ( diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx index 4a09927346..0f3fd76a65 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx @@ -3,14 +3,16 @@ import { observer } from "mobx-react"; import useSWR from "swr"; import { setPromiseToast } from "@plane/propel/toast"; import { Loader, ToggleSwitch } from "@plane/ui"; -// components +// assets import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; +// components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// icons -// local components +// types import type { Route } from "./+types/page"; +// local import { InstanceGitlabConfigForm } from "./form"; const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthenticationPage( @@ -35,7 +37,7 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent const updateConfigPromise = updateInstanceConfigurations(payload); setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", + loading: "Saving Configuration", success: { title: "Configuration saved", message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`, @@ -56,46 +58,43 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent }); }; return ( - <> -
-
- } - config={ - { - if (Boolean(parseInt(enableGitlabConfig)) === true) { - updateConfig("IS_GITLAB_ENABLED", "0"); - } else { - updateConfig("IS_GITLAB_ENABLED", "1"); - } - }} - size="sm" - disabled={isSubmitting || !formattedConfig} - /> - } - disabled={isSubmitting || !formattedConfig} - withBorder={false} - /> -
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- + } + config={ + { + if (Boolean(parseInt(enableGitlabConfig)) === true) { + updateConfig("IS_GITLAB_ENABLED", "0"); + } else { + updateConfig("IS_GITLAB_ENABLED", "1"); + } + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> + } + > + {formattedConfig ? ( + + ) : ( + + + + + + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx index c1b328633c..e068a1d07b 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx @@ -8,11 +8,12 @@ import { API_BASE_URL } from "@plane/constants"; import { Button, getButtonStyling } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; -import { cn } from "@plane/utils"; // components import { CodeBlock } from "@/components/common/code-block"; import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; import type { TControllerInputFormField } from "@/components/common/controller-input"; +import type { TControllerSwitchFormField } from "@/components/common/controller-switch"; +import { ControllerSwitch } from "@/components/common/controller-switch"; import { ControllerInput } from "@/components/common/controller-input"; import type { TCopyField } from "@/components/common/copy-field"; import { CopyField } from "@/components/common/copy-field"; @@ -41,6 +42,7 @@ export function InstanceGoogleConfigForm(props: Props) { defaultValues: { GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"], GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"], + ENABLE_GOOGLE_SYNC: config["ENABLE_GOOGLE_SYNC"] || "0", }, }); @@ -93,6 +95,11 @@ export function InstanceGoogleConfigForm(props: Props) { }, ]; + const GOOGLE_FORM_SWITCH_FIELD: TControllerSwitchFormField = { + name: "ENABLE_GOOGLE_SYNC", + label: "Google", + }; + const GOOGLE_COMMON_SERVICE_DETAILS: TCopyField[] = [ { key: "Origin_URL", @@ -140,19 +147,21 @@ export function InstanceGoogleConfigForm(props: Props) { const onSubmit = async (formData: GoogleConfigFormValues) => { const payload: Partial = { ...formData }; - await updateInstanceConfigurations(payload) - .then((response = []) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Done!", - message: "Your Google authentication is configured. You should test it now.", - }); - reset({ - GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value, - GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value, - }); - }) - .catch((err) => console.error(err)); + try { + const response = await updateInstanceConfigurations(payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your Google authentication is configured. You should test it now.", + }); + reset({ + GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value, + GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value, + ENABLE_GOOGLE_SYNC: response.find((item) => item.key === "ENABLE_GOOGLE_SYNC")?.value, + }); + } catch (err) { + console.error(err); + } }; const handleGoBack = (e: React.MouseEvent) => { @@ -186,16 +195,17 @@ export function InstanceGoogleConfigForm(props: Props) { required={field.required} /> ))} +
Go back @@ -216,7 +226,7 @@ export function InstanceGoogleConfigForm(props: Props) { {/* web service details */}
-
+
Web
diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx index 8089fcd640..bf2a18d5a8 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx @@ -3,14 +3,16 @@ import { observer } from "mobx-react"; import useSWR from "swr"; import { setPromiseToast } from "@plane/propel/toast"; import { Loader, ToggleSwitch } from "@plane/ui"; -// components +// assets import GoogleLogo from "@/app/assets/logos/google-logo.svg?url"; +// components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// icons -// local components +// types import type { Route } from "./+types/page"; +// local import { InstanceGoogleConfigForm } from "./form"; const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthenticationPage( @@ -35,7 +37,7 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent const updateConfigPromise = updateInstanceConfigurations(payload); setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", + loading: "Saving Configuration", success: { title: "Configuration saved", message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`, @@ -56,47 +58,44 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent }); }; return ( - <> -
-
- } - config={ - { - if (Boolean(parseInt(enableGoogleConfig)) === true) { - updateConfig("IS_GOOGLE_ENABLED", "0"); - } else { - updateConfig("IS_GOOGLE_ENABLED", "1"); - } - }} - size="sm" - disabled={isSubmitting || !formattedConfig} - /> - } - disabled={isSubmitting || !formattedConfig} - withBorder={false} - /> -
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- + icon={Google Logo} + config={ + { + if (Boolean(parseInt(enableGoogleConfig)) === true) { + updateConfig("IS_GOOGLE_ENABLED", "0"); + } else { + updateConfig("IS_GOOGLE_ENABLED", "1"); + } + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> + } + > + {formattedConfig ? ( + + ) : ( + + + + + + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/authentication/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx index 2ea34a3cac..10adc31297 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx @@ -1,27 +1,33 @@ import { useState } from "react"; import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; import useSWR from "swr"; // plane internal packages import { setPromiseToast } from "@plane/propel/toast"; import type { TInstanceConfigurationKeys } from "@plane/types"; import { Loader, ToggleSwitch } from "@plane/ui"; -import { cn } from "@plane/utils"; +import { cn, resolveGeneralTheme } from "@plane/utils"; +// components +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { useAuthenticationModes } from "@/hooks/oauth"; import { useInstance } from "@/hooks/store"; -// plane admin components -import { AuthenticationModes } from "@/plane-admin/components/authentication"; +// types import type { Route } from "./+types/page"; const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(_props: Route.ComponentProps) { + // theme + const { resolvedTheme: resolvedThemeAdmin } = useTheme(); // store const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); - - useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); - // state const [isSubmitting, setIsSubmitting] = useState(false); // derived values const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? ""; + const resolvedTheme = resolveGeneralTheme(resolvedThemeAdmin); + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { setIsSubmitting(true); @@ -54,59 +60,65 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage( }); }; + const authenticationModes = useAuthenticationModes({ disabled: isSubmitting, updateConfig, resolvedTheme }); return ( - <> -
-
-
Manage authentication modes for your instance
-
- Configure authentication modes for your team and restrict sign-ups to be invite only. -
-
-
- {formattedConfig ? ( -
-
-
-
-
Allow anyone to sign up even without an invite
-
- Toggling this off will only let users sign up when they are invited. -
-
-
-
-
- { - if (Boolean(parseInt(enableSignUpConfig)) === true) { - updateConfig("ENABLE_SIGNUP", "0"); - } else { - updateConfig("ENABLE_SIGNUP", "1"); - } - }} - size="sm" - disabled={isSubmitting} - /> -
+ + {formattedConfig ? ( +
+
+
+
+
Allow anyone to sign up even without an invite
+
+ Toggling this off will only let users sign up when they are invited.
-
Available authentication modes
-
- ) : ( - - - - - - - - )} +
+
+ { + if (Boolean(parseInt(enableSignUpConfig)) === true) { + updateConfig("ENABLE_SIGNUP", "0"); + } else { + updateConfig("ENABLE_SIGNUP", "1"); + } + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+
Available authentication modes
+ {authenticationModes.map((method) => ( + + ))}
-
- + ) : ( + + + + + + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx index e8ae5ed8d6..5fdc66b91c 100644 --- a/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx +++ b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx @@ -209,7 +209,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) { loading={isSubmitting} disabled={!isValid || !isDirty} > - {isSubmitting ? "Saving..." : "Save changes"} + {isSubmitting ? "Saving" : "Save changes"} {sendEmailStep === ESendEmailSteps.SEND_EMAIL && ( )}
diff --git a/apps/admin/app/(all)/(dashboard)/general/form.tsx b/apps/admin/app/(all)/(dashboard)/general/form.tsx index 0b5619ea00..ff7b00e0dc 100644 --- a/apps/admin/app/(all)/(dashboard)/general/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/form.tsx @@ -1,17 +1,17 @@ import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { Telescope } from "lucide-react"; -// types +// plane imports import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IInstance, IInstanceAdmin } from "@plane/types"; -// ui import { Input, ToggleSwitch } from "@plane/ui"; // components import { ControllerInput } from "@/components/common/controller-input"; -import { useInstance } from "@/hooks/store"; -import { IntercomConfig } from "./intercom"; // hooks +import { useInstance } from "@/hooks/store"; +// components +import { IntercomConfig } from "./intercom"; export interface IGeneralConfigurationForm { instance: IInstance; @@ -27,8 +27,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo const { handleSubmit, control, - watch, formState: { errors, isSubmitting }, + watch, } = useForm>({ defaultValues: { instance_name: instance?.instance_name, @@ -105,14 +105,14 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
-
-
Chat + telemetry
+
+
Chat + telemetry
-
+
-
- +
+
@@ -144,8 +144,15 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
-
diff --git a/apps/admin/app/(all)/(dashboard)/general/intercom.tsx b/apps/admin/app/(all)/(dashboard)/general/intercom.tsx index d5ec8d352e..0704b7c47b 100644 --- a/apps/admin/app/(all)/(dashboard)/general/intercom.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/intercom.tsx @@ -44,16 +44,16 @@ export const IntercomConfig = observer(function IntercomConfig(props: TIntercomC }; const enableIntercomConfig = () => { - submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" }); + void submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" }); }; return ( <> -
+
-
- +
+
diff --git a/apps/admin/app/(all)/(dashboard)/general/page.tsx b/apps/admin/app/(all)/(dashboard)/general/page.tsx index fd7efd3099..6e0a86d52a 100644 --- a/apps/admin/app/(all)/(dashboard)/general/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/page.tsx @@ -1,30 +1,26 @@ import { observer } from "mobx-react"; +// components +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// components -import type { Route } from "./+types/page"; +// local imports import { GeneralConfigurationForm } from "./form"; +// types +import type { Route } from "./+types/page"; function GeneralPage() { const { instance, instanceAdmins } = useInstance(); return ( - <> -
-
-
General settings
-
- Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your - instance. -
-
-
- {instance && instanceAdmins && ( - - )} -
-
- + + {instance && instanceAdmins && } + ); } diff --git a/apps/admin/app/(all)/(dashboard)/image/form.tsx b/apps/admin/app/(all)/(dashboard)/image/form.tsx index 7d12ee59f7..154e74bf12 100644 --- a/apps/admin/app/(all)/(dashboard)/image/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/image/form.tsx @@ -71,7 +71,7 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
diff --git a/apps/admin/app/(all)/(dashboard)/image/page.tsx b/apps/admin/app/(all)/(dashboard)/image/page.tsx index 5bd2914d54..7b89393f27 100644 --- a/apps/admin/app/(all)/(dashboard)/image/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/image/page.tsx @@ -1,10 +1,13 @@ import { observer } from "mobx-react"; import useSWR from "swr"; import { Loader } from "@plane/ui"; +// components +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// local +// types import type { Route } from "./+types/page"; +// local import { InstanceImageConfigForm } from "./form"; const InstanceImagePage = observer(function InstanceImagePage(_props: Route.ComponentProps) { @@ -14,26 +17,21 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); return ( - <> -
-
-
Third-party image libraries
-
- Let your users search and choose images from third-party libraries -
-
-
- {formattedConfig ? ( - - ) : ( - - - - - )} -
-
- + + {formattedConfig ? ( + + ) : ( + + + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/layout.tsx b/apps/admin/app/(all)/(dashboard)/layout.tsx index 2798c8f043..ba5564bf52 100644 --- a/apps/admin/app/(all)/(dashboard)/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/layout.tsx @@ -3,13 +3,13 @@ import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; import { Outlet } from "react-router"; // components +import { AdminHeader } from "@/components/common/header"; import { LogoSpinner } from "@/components/common/logo-spinner"; import { NewUserPopup } from "@/components/new-user-popup"; // hooks import { useUser } from "@/hooks/store"; // local components import type { Route } from "./+types/layout"; -import { AdminHeader } from "./header"; import { AdminSidebar } from "./sidebar"; function AdminLayout(_props: Route.ComponentProps) { diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx index 5babcbbd40..ed068d32ee 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx @@ -71,14 +71,14 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() { useEffect(() => { if (csrfToken === undefined) - authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + void authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); }, [csrfToken]); return ( -
+
@@ -88,8 +88,8 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() { "cursor-default": !isSidebarCollapsed, })} > -
- +
+
{isSidebarCollapsed && ( @@ -109,7 +109,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() { {!isSidebarCollapsed && (
-

Instance admin

+

Instance admin

)}
@@ -123,7 +123,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() { src={getFileURL(currentUser.avatar_url)} size={24} shape="square" - className="!text-14" + className="!text-body-sm-medium" /> diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx index 8d0b87ac98..146770c70d 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx @@ -9,11 +9,9 @@ import { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // hooks -import { useTheme } from "@/hooks/store"; +import { useInstance, useTheme } from "@/hooks/store"; // assets -import packageJson from "package.json"; - const helpOptions = [ { name: "Documentation", @@ -36,6 +34,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection // states const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); // store + const { instance } = useInstance(); const { isSidebarCollapsed, toggleSidebar } = useTheme(); // refs const helpOptionsRef = useRef(null); @@ -55,9 +54,9 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection - + {!isSidebarCollapsed && "Redirect to Plane"} @@ -69,7 +68,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection }`} onClick={() => setIsNeedHelpOpen((prev) => !prev)} > - + @@ -80,7 +79,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection }`} onClick={() => toggleSidebar(!isSidebarCollapsed)} > - +
@@ -98,7 +97,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
@@ -108,7 +107,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
- +
{name}
@@ -129,7 +128,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection ); })}
-
Version: v{packageJson.version}
+
Version: v{instance?.current_version}
diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx index e7371fc69e..3a81db3cc2 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx @@ -1,58 +1,20 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; // plane internal packages -import { WorkspaceIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // hooks import { useTheme } from "@/hooks/store"; - -const INSTANCE_ADMIN_LINKS = [ - { - Icon: Cog, - name: "General", - description: "Identify your instances and get key details.", - href: `/general/`, - }, - { - Icon: WorkspaceIcon, - name: "Workspaces", - description: "Manage all workspaces on this instance.", - href: `/workspace/`, - }, - { - Icon: Mail, - name: "Email", - description: "Configure your SMTP controls.", - href: `/email/`, - }, - { - Icon: Lock, - name: "Authentication", - description: "Configure authentication modes.", - href: `/authentication/`, - }, - { - Icon: BrainCog, - name: "Artificial intelligence", - description: "Configure your OpenAI creds.", - href: `/ai/`, - }, - { - Icon: Image, - name: "Images in Plane", - description: "Allow third-party image libraries.", - href: `/image/`, - }, -]; +import { useSidebarMenu } from "@/hooks/use-sidebar-menu"; export const AdminSidebarMenu = observer(function AdminSidebarMenu() { - // store hooks - const { isSidebarCollapsed, toggleSidebar } = useTheme(); // router const pathName = usePathname(); + // store hooks + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + // derived values + const sidebarMenu = useSidebarMenu(); const handleItemClick = () => { if (window.innerWidth < 768) { @@ -62,40 +24,27 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() { return (
- {INSTANCE_ADMIN_LINKS.map((item, index) => { - const isActive = item.href === pathName || pathName.includes(item.href); + {sidebarMenu.map((item, index) => { + const isActive = item.href === pathName || pathName?.includes(item.href); return (
{} {!isSidebarCollapsed && (
-
- {item.name} -
-
- {item.description} -
+
{item.name}
+
{item.description}
)}
diff --git a/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx index 3fd441c268..30950d8783 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx @@ -1,21 +1,21 @@ import { observer } from "mobx-react"; // components +import { PageWrapper } from "@/components/common/page-wrapper"; +// types import type { Route } from "./+types/page"; +// local import { WorkspaceCreateForm } from "./form"; const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.ComponentProps) { return ( -
-
-
Create a new workspace on this instance.
-
- You will need to invite users from Workspace Settings after you create this workspace. -
-
-
- -
-
+ + + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/workspace/page.tsx b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx index c3222f1fb9..add3da10a3 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx @@ -8,12 +8,13 @@ import { Button, getButtonStyling } from "@plane/propel/button"; import { setPromiseToast } from "@plane/propel/toast"; import type { TInstanceConfigurationKeys } from "@plane/types"; import { Loader, ToggleSwitch } from "@plane/ui"; - import { cn } from "@plane/utils"; // components +import { PageWrapper } from "@/components/common/page-wrapper"; import { WorkspaceListItem } from "@/components/workspace/list-item"; // hooks import { useInstance, useWorkspace } from "@/hooks/store"; +// types import type { Route } from "./+types/page"; const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props: Route.ComponentProps) { @@ -68,99 +69,95 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props }; return ( -
-
-
-
Workspaces on this instance
-
See all workspaces and control who can create them.
-
-
-
-
- {formattedConfig ? ( -
-
-
-
Prevent anyone else from creating a workspace.
-
- Toggling this on will let only you create workspaces. You will have to invite users to new - workspaces. -
-
-
-
-
- { - if (Boolean(parseInt(disableWorkspaceCreation)) === true) { - updateConfig("DISABLE_WORKSPACE_CREATION", "0"); - } else { - updateConfig("DISABLE_WORKSPACE_CREATION", "1"); - } - }} - size="sm" - disabled={isSubmitting} - /> + +
+ {formattedConfig ? ( +
+
+
+
Prevent anyone else from creating a workspace.
+
+ Toggling this on will let only you create workspaces. You will have to invite users to new workspaces.
- ) : ( - - - - )} - {workspaceLoader !== "init-loader" ? ( - <> -
-
-
- All workspaces on this instance • {workspaceIds.length} - {workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && ( - - )} -
-
- You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a - Member. -
+
+
+ { + if (Boolean(parseInt(disableWorkspaceCreation)) === true) { + updateConfig("DISABLE_WORKSPACE_CREATION", "0"); + } else { + updateConfig("DISABLE_WORKSPACE_CREATION", "1"); + } + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+ ) : ( + + + + )} + {workspaceLoader !== "init-loader" ? ( + <> +
+
+
+ All workspaces on this instance • {workspaceIds.length} + {workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && ( + + )}
-
- - Create workspace - +
+ You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a + Member.
-
- {workspaceIds.map((workspaceId) => ( - - ))} +
+ + Create workspace +
- {hasNextPage && ( -
- -
- )} - - ) : ( - - - - - - - )} -
+
+
+ {workspaceIds.map((workspaceId) => ( + + ))} +
+ {hasNextPage && ( +
+ +
+ )} + + ) : ( + + + + + + + )}
-
+ ); }); diff --git a/apps/admin/app/(all)/(home)/auth-banner.tsx b/apps/admin/app/(all)/(home)/auth-banner.tsx index 61fc194719..a2db1ad656 100644 --- a/apps/admin/app/(all)/(home)/auth-banner.tsx +++ b/apps/admin/app/(all)/(home)/auth-banner.tsx @@ -20,7 +20,7 @@ export function AuthBanner(props: TAuthBanner) {
{bannerData?.message}
handleBannerData && handleBannerData(undefined)} > diff --git a/apps/admin/app/(all)/(home)/auth-helpers.tsx b/apps/admin/app/(all)/(home)/auth-helpers.tsx index d13147362c..18f793cd13 100644 --- a/apps/admin/app/(all)/(home)/auth-helpers.tsx +++ b/apps/admin/app/(all)/(home)/auth-helpers.tsx @@ -117,14 +117,14 @@ export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps name: "Unique codes", description: "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.", - icon: , + icon: , config: , }, { key: "passwords-login", name: "Passwords", description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", - icon: , + icon: , config: , }, { diff --git a/apps/admin/app/root.tsx b/apps/admin/app/root.tsx index cbae375398..a2a5c216be 100644 --- a/apps/admin/app/root.tsx +++ b/apps/admin/app/root.tsx @@ -1,15 +1,20 @@ import type { ReactNode } from "react"; -import * as Sentry from "@sentry/react-router"; import { Links, Meta, Outlet, Scripts } from "react-router"; import type { LinksFunction } from "react-router"; +import * as Sentry from "@sentry/react-router"; import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url"; import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url"; import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url"; import faviconIco from "@/app/assets/favicon/favicon.ico?url"; import { LogoSpinner } from "@/components/common/logo-spinner"; import globalStyles from "@/styles/globals.css?url"; +import { AppProviders } from "@/providers"; import type { Route } from "./+types/root"; -import { AppProviders } from "./providers"; +// fonts +import "@fontsource-variable/inter"; +import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url"; +import "@fontsource/material-symbols-rounded"; +import "@fontsource/ibm-plex-mono"; const APP_TITLE = "Plane | Simple, extensible, open-source project management tool."; const APP_DESCRIPTION = @@ -22,6 +27,13 @@ export const links: LinksFunction = () => [ { rel: "shortcut icon", href: faviconIco }, { rel: "manifest", href: `/site.webmanifest.json` }, { rel: "stylesheet", href: globalStyles }, + { + rel: "preload", + href: interVariableWoff2, + as: "font", + type: "font/woff2", + crossOrigin: "anonymous", + }, ]; export function Layout({ children }: { children: ReactNode }) { diff --git a/apps/admin/ce/components/authentication/index.ts b/apps/admin/ce/components/authentication/index.ts deleted file mode 100644 index d2aa748557..0000000000 --- a/apps/admin/ce/components/authentication/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./authentication-modes"; diff --git a/apps/admin/ce/components/common/index.ts b/apps/admin/ce/components/common/index.ts deleted file mode 100644 index c6a1da8b62..0000000000 --- a/apps/admin/ce/components/common/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./upgrade-button"; diff --git a/apps/admin/ce/components/common/upgrade-button.tsx b/apps/admin/ce/components/common/upgrade-button.tsx deleted file mode 100644 index cf656bbd5e..0000000000 --- a/apps/admin/ce/components/common/upgrade-button.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from "react"; -// icons -import { SquareArrowOutUpRight } from "lucide-react"; -// plane internal packages -import { getButtonStyling } from "@plane/propel/button"; -import { cn } from "@plane/utils"; - -export function UpgradeButton() { - return ( - - Upgrade - - - ); -} diff --git a/apps/admin/core/components/authentication/authentication-method-card.tsx b/apps/admin/core/components/authentication/authentication-method-card.tsx index df330aa71c..b420d13fb0 100644 --- a/apps/admin/core/components/authentication/authentication-method-card.tsx +++ b/apps/admin/core/components/authentication/authentication-method-card.tsx @@ -16,7 +16,7 @@ export function AuthenticationMethodCard(props: Props) { return (
diff --git a/apps/admin/core/components/authentication/gitea-config.tsx b/apps/admin/core/components/authentication/gitea-config.tsx index 7fadf1e1a3..e17e23ba10 100644 --- a/apps/admin/core/components/authentication/gitea-config.tsx +++ b/apps/admin/core/components/authentication/gitea-config.tsx @@ -44,7 +44,7 @@ export const GiteaConfiguration = observer(function GiteaConfiguration(props: Pr
) : ( - + Configure )} diff --git a/apps/admin/core/components/authentication/github-config.tsx b/apps/admin/core/components/authentication/github-config.tsx index 46a1a708c9..b058ecf1bd 100644 --- a/apps/admin/core/components/authentication/github-config.tsx +++ b/apps/admin/core/components/authentication/github-config.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { observer } from "mobx-react"; import Link from "next/link"; // icons @@ -43,7 +42,7 @@ export const GithubConfiguration = observer(function GithubConfiguration(props:
) : ( - + Configure )} diff --git a/apps/admin/core/components/authentication/gitlab-config.tsx b/apps/admin/core/components/authentication/gitlab-config.tsx index b3069e6cc0..697f407d56 100644 --- a/apps/admin/core/components/authentication/gitlab-config.tsx +++ b/apps/admin/core/components/authentication/gitlab-config.tsx @@ -42,7 +42,7 @@ export const GitlabConfiguration = observer(function GitlabConfiguration(props:
) : ( - + Configure )} diff --git a/apps/admin/core/components/authentication/google-config.tsx b/apps/admin/core/components/authentication/google-config.tsx index 61d89e3325..a130a82471 100644 --- a/apps/admin/core/components/authentication/google-config.tsx +++ b/apps/admin/core/components/authentication/google-config.tsx @@ -42,7 +42,7 @@ export const GoogleConfiguration = observer(function GoogleConfiguration(props:
) : ( - + Configure )} diff --git a/apps/admin/core/components/common/confirm-discard-modal.tsx b/apps/admin/core/components/common/confirm-discard-modal.tsx index 159115bae4..6b9d6f5963 100644 --- a/apps/admin/core/components/common/confirm-discard-modal.tsx +++ b/apps/admin/core/components/common/confirm-discard-modal.tsx @@ -39,7 +39,7 @@ export function ConfirmDiscardModal(props: Props) { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
diff --git a/apps/admin/core/components/common/controller-switch.tsx b/apps/admin/core/components/common/controller-switch.tsx new file mode 100644 index 0000000000..f20d6cafe3 --- /dev/null +++ b/apps/admin/core/components/common/controller-switch.tsx @@ -0,0 +1,38 @@ +import type { Control, FieldPath, FieldValues } from "react-hook-form"; +import { Controller } from "react-hook-form"; +// plane internal packages +import { ToggleSwitch } from "@plane/ui"; + +type Props = { + control: Control; + field: TControllerSwitchFormField; +}; + +export type TControllerSwitchFormField = { + name: FieldPath; + label: string; +}; + +export function ControllerSwitch(props: Props) { + const { + control, + field: { name, label }, + } = props; + + return ( +
+

Refresh user attributes from {label} during sign in

+
+ } + render={({ field: { value, onChange } }) => { + const parsedValue = Number.parseInt(typeof value === "string" ? value : String(value ?? "0"), 10); + const isOn = !Number.isNaN(parsedValue) && parsedValue !== 0; + return onChange(isOn ? "0" : "1")} size="sm" />; + }} + /> +
+
+ ); +} diff --git a/apps/admin/core/components/common/header/core.ts b/apps/admin/core/components/common/header/core.ts new file mode 100644 index 0000000000..db77b83fa1 --- /dev/null +++ b/apps/admin/core/components/common/header/core.ts @@ -0,0 +1,13 @@ +export const CORE_HEADER_SEGMENT_LABELS: Record = { + general: "General", + ai: "Artificial Intelligence", + email: "Email", + authentication: "Authentication", + image: "Image", + google: "Google", + github: "GitHub", + gitlab: "GitLab", + gitea: "Gitea", + workspace: "Workspace", + create: "Create", +}; diff --git a/apps/admin/core/components/common/header/extended.ts b/apps/admin/core/components/common/header/extended.ts new file mode 100644 index 0000000000..152b082498 --- /dev/null +++ b/apps/admin/core/components/common/header/extended.ts @@ -0,0 +1 @@ +export const EXTENDED_HEADER_SEGMENT_LABELS: Record = {}; diff --git a/apps/admin/app/(all)/(dashboard)/header.tsx b/apps/admin/core/components/common/header/index.tsx similarity index 70% rename from apps/admin/app/(all)/(dashboard)/header.tsx rename to apps/admin/core/components/common/header/index.tsx index e769d38602..65d6f5703b 100644 --- a/apps/admin/app/(all)/(dashboard)/header.tsx +++ b/apps/admin/core/components/common/header/index.tsx @@ -7,51 +7,30 @@ import { Breadcrumbs } from "@plane/ui"; import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; // hooks import { useTheme } from "@/hooks/store"; +// local imports +import { CORE_HEADER_SEGMENT_LABELS } from "./core"; +import { EXTENDED_HEADER_SEGMENT_LABELS } from "./extended"; export const HamburgerToggle = observer(function HamburgerToggle() { const { isSidebarCollapsed, toggleSidebar } = useTheme(); return ( -
toggleSidebar(!isSidebarCollapsed)} > -
+ ); }); +const HEADER_SEGMENT_LABELS = { + ...CORE_HEADER_SEGMENT_LABELS, + ...EXTENDED_HEADER_SEGMENT_LABELS, +}; + export const AdminHeader = observer(function AdminHeader() { const pathName = usePathname(); - const getHeaderTitle = (pathName: string) => { - switch (pathName) { - case "general": - return "General"; - case "ai": - return "Artificial Intelligence"; - case "email": - return "Email"; - case "authentication": - return "Authentication"; - case "image": - return "Image"; - case "google": - return "Google"; - case "github": - return "GitHub"; - case "gitlab": - return "GitLab"; - case "gitea": - return "Gitea"; - case "workspace": - return "Workspace"; - case "create": - return "Create"; - default: - return pathName.toUpperCase(); - } - }; - // Function to dynamically generate breadcrumb items based on pathname const generateBreadcrumbItems = (pathname: string) => { const pathSegments = pathname.split("/").slice(1); // removing the first empty string. @@ -61,14 +40,14 @@ export const AdminHeader = observer(function AdminHeader() { const breadcrumbItems = pathSegments.map((segment) => { currentUrl += "/" + segment; return { - title: getHeaderTitle(segment), + title: HEADER_SEGMENT_LABELS[segment] ?? segment.toUpperCase(), href: currentUrl, }; }); return breadcrumbItems; }; - const breadcrumbItems = generateBreadcrumbItems(pathName); + const breadcrumbItems = generateBreadcrumbItems(pathName || ""); return (
diff --git a/apps/admin/core/components/common/page-wrapper.tsx b/apps/admin/core/components/common/page-wrapper.tsx new file mode 100644 index 0000000000..e2f35a4884 --- /dev/null +++ b/apps/admin/core/components/common/page-wrapper.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; +// plane imports +import { cn } from "@plane/utils"; + +type TPageWrapperProps = { + children: ReactNode; + header?: { + title: string; + description: string | ReactNode; + actions?: ReactNode; + }; + customHeader?: ReactNode; + size?: "lg" | "md"; +}; + +export const PageWrapper = (props: TPageWrapperProps) => { + const { children, header, customHeader, size = "md" } = props; + + return ( +
+ {customHeader ? ( +
{customHeader}
+ ) : ( + header && ( +
+
+
{header.title}
+
{header.description}
+
+ {header.actions &&
{header.actions}
} +
+ ) + )} +
+ {children} +
+
+ ); +}; diff --git a/apps/admin/core/components/instance/setup-form.tsx b/apps/admin/core/components/instance/setup-form.tsx index 4e4e199504..4d98885e55 100644 --- a/apps/admin/core/components/instance/setup-form.tsx +++ b/apps/admin/core/components/instance/setup-form.tsx @@ -54,13 +54,13 @@ const defaultFromData: TFormData = { export function InstanceSetupForm() { // 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; + 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({ password: false, @@ -238,7 +238,7 @@ export function InstanceSetupForm() { name="password" type={showPassword.password ? "text" : "password"} inputSize="md" - placeholder="New password..." + placeholder="New password" value={formData.password} onChange={(e) => handleFormChange("password", e.target.value)} hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false} diff --git a/apps/admin/core/components/workspace/list-item.tsx b/apps/admin/core/components/workspace/list-item.tsx index 2f55ba2a1b..c64ea648de 100644 --- a/apps/admin/core/components/workspace/list-item.tsx +++ b/apps/admin/core/components/workspace/list-item.tsx @@ -23,13 +23,13 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace key={workspaceId} href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`} target="_blank" - className="group flex items-center justify-between p-4 gap-2.5 truncate border border-subtle/70 hover:border-subtle bg-layer-1 hover:bg-layer-1-hover rounded-md" + className="group flex items-center justify-between p-3 gap-2.5 truncate border border-subtle hover:border-subtle-1 bg-layer-1 hover:bg-layer-1-hover hover:shadow-raised-100 rounded-lg" rel="noreferrer" >
{workspace?.logo_url && workspace.logo_url !== "" ? ( @@ -75,7 +75,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
- +
); diff --git a/apps/admin/ce/components/authentication/authentication-modes.tsx b/apps/admin/core/hooks/oauth/core.tsx similarity index 51% rename from apps/admin/ce/components/authentication/authentication-modes.tsx rename to apps/admin/core/hooks/oauth/core.tsx index 9c2348c81d..dc04b40ff7 100644 --- a/apps/admin/ce/components/authentication/authentication-modes.tsx +++ b/apps/admin/core/hooks/oauth/core.tsx @@ -1,72 +1,61 @@ -import { observer } from "mobx-react"; -import { useTheme } from "next-themes"; import { KeyRound, Mails } from "lucide-react"; // types import type { + TCoreInstanceAuthenticationModeKeys, TGetBaseAuthenticationModeProps, - TInstanceAuthenticationMethodKeys, TInstanceAuthenticationModes, } from "@plane/types"; -import { resolveGeneralTheme } from "@plane/utils"; -// components +// assets import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; import githubLightModeImage from "@/app/assets/logos/github-black.png?url"; import githubDarkModeImage from "@/app/assets/logos/github-white.png?url"; -import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; -import GoogleLogo from "@/app/assets/logos/google-logo.svg?url"; -import OIDCLogo from "@/app/assets/logos/oidc-logo.svg?url"; -import SAMLLogo from "@/app/assets/logos/saml-logo.svg?url"; -import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import gitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; +import googleLogo from "@/app/assets/logos/google-logo.svg?url"; +// components import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; import { GiteaConfiguration } from "@/components/authentication/gitea-config"; import { GithubConfiguration } from "@/components/authentication/github-config"; import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; import { GoogleConfiguration } from "@/components/authentication/google-config"; import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch"; -// plane admin components -import { UpgradeButton } from "@/plane-admin/components/common"; -// assets - -export type TAuthenticationModeProps = { - disabled: boolean; - updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; -}; // Authentication methods -export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({ +export const getCoreAuthenticationModesMap: ( + props: TGetBaseAuthenticationModeProps +) => Record = ({ disabled, updateConfig, resolvedTheme, -}) => [ - { +}) => ({ + "unique-codes": { key: "unique-codes", name: "Unique codes", description: "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.", - icon: , + icon: , config: , }, - { + "passwords-login": { key: "passwords-login", name: "Passwords", description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", - icon: , + icon: , config: , }, - { + google: { key: "google", name: "Google", description: "Allow members to log in or sign up for Plane with their Google accounts.", - icon: Google Logo, + icon: Google Logo, config: , }, - { + github: { key: "github", name: "GitHub", description: "Allow members to log in or sign up for Plane with their GitHub accounts.", icon: ( GitHub Logo ), config: , }, - { + gitlab: { key: "gitlab", name: "GitLab", description: "Allow members to log in or sign up to plane with their GitLab accounts.", - icon: GitLab Logo, + icon: GitLab Logo, config: , }, - { + gitea: { key: "gitea", name: "Gitea", description: "Allow members to log in or sign up to plane with their Gitea accounts.", icon: Gitea Logo, config: , }, - { - key: "oidc", - name: "OIDC", - description: "Authenticate your users via the OpenID Connect protocol.", - icon: OIDC Logo, - config: , - unavailable: true, - }, - { - key: "saml", - name: "SAML", - description: "Authenticate your users via the Security Assertion Markup Language protocol.", - icon: SAML Logo, - config: , - unavailable: true, - }, -]; - -export const AuthenticationModes = observer(function AuthenticationModes(props: TAuthenticationModeProps) { - const { disabled, updateConfig } = props; - // next-themes - const { resolvedTheme } = useTheme(); - - return ( -
- {getAuthenticationModes({ disabled, updateConfig, resolvedTheme }).map((method) => ( - - ))} -
- ); }); diff --git a/apps/admin/core/hooks/oauth/index.ts b/apps/admin/core/hooks/oauth/index.ts new file mode 100644 index 0000000000..2982814e5b --- /dev/null +++ b/apps/admin/core/hooks/oauth/index.ts @@ -0,0 +1,19 @@ +import type { TInstanceAuthenticationModes } from "@plane/types"; +import { getCoreAuthenticationModesMap } from "./core"; +import type { TGetAuthenticationModeProps } from "./types"; + +export const useAuthenticationModes = (props: TGetAuthenticationModeProps): TInstanceAuthenticationModes[] => { + // derived values + const authenticationModes = getCoreAuthenticationModesMap(props); + + const availableAuthenticationModes: TInstanceAuthenticationModes[] = [ + authenticationModes["unique-codes"], + authenticationModes["passwords-login"], + authenticationModes["google"], + authenticationModes["github"], + authenticationModes["gitlab"], + authenticationModes["gitea"], + ]; + + return availableAuthenticationModes; +}; diff --git a/apps/admin/core/hooks/oauth/types.ts b/apps/admin/core/hooks/oauth/types.ts new file mode 100644 index 0000000000..cf265152ac --- /dev/null +++ b/apps/admin/core/hooks/oauth/types.ts @@ -0,0 +1,7 @@ +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; + +export type TGetAuthenticationModeProps = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; + resolvedTheme: string | undefined; +}; diff --git a/apps/admin/core/hooks/store/use-instance.tsx b/apps/admin/core/hooks/store/use-instance.tsx index 5917df3fa0..508e8fe1ec 100644 --- a/apps/admin/core/hooks/store/use-instance.tsx +++ b/apps/admin/core/hooks/store/use-instance.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/app/(all)/store.provider"; +import { StoreContext } from "@/providers/store.provider"; import type { IInstanceStore } from "@/store/instance.store"; export const useInstance = (): IInstanceStore => { diff --git a/apps/admin/core/hooks/store/use-theme.tsx b/apps/admin/core/hooks/store/use-theme.tsx index d5a1e820e3..289ea6ae4a 100644 --- a/apps/admin/core/hooks/store/use-theme.tsx +++ b/apps/admin/core/hooks/store/use-theme.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/app/(all)/store.provider"; +import { StoreContext } from "@/providers/store.provider"; import type { IThemeStore } from "@/store/theme.store"; export const useTheme = (): IThemeStore => { diff --git a/apps/admin/core/hooks/store/use-user.tsx b/apps/admin/core/hooks/store/use-user.tsx index 56b988eb80..80cd046b49 100644 --- a/apps/admin/core/hooks/store/use-user.tsx +++ b/apps/admin/core/hooks/store/use-user.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/app/(all)/store.provider"; +import { StoreContext } from "@/providers/store.provider"; import type { IUserStore } from "@/store/user.store"; export const useUser = (): IUserStore => { diff --git a/apps/admin/core/hooks/store/use-workspace.tsx b/apps/admin/core/hooks/store/use-workspace.tsx index c4578c9170..957a33ff3c 100644 --- a/apps/admin/core/hooks/store/use-workspace.tsx +++ b/apps/admin/core/hooks/store/use-workspace.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/app/(all)/store.provider"; +import { StoreContext } from "@/providers/store.provider"; import type { IWorkspaceStore } from "@/store/workspace.store"; export const useWorkspace = (): IWorkspaceStore => { diff --git a/apps/admin/core/hooks/use-sidebar-menu/core.ts b/apps/admin/core/hooks/use-sidebar-menu/core.ts new file mode 100644 index 0000000000..36b143598d --- /dev/null +++ b/apps/admin/core/hooks/use-sidebar-menu/core.ts @@ -0,0 +1,46 @@ +import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; +// plane imports +import { WorkspaceIcon } from "@plane/propel/icons"; +// types +import type { TSidebarMenuItem } from "./types"; + +export type TCoreSidebarMenuKey = "general" | "email" | "workspace" | "authentication" | "ai" | "image"; + +export const coreSidebarMenuLinks: Record = { + general: { + Icon: Cog, + name: "General", + description: "Identify your instances and get key details.", + href: `/general/`, + }, + email: { + Icon: Mail, + name: "Email", + description: "Configure your SMTP controls.", + href: `/email/`, + }, + workspace: { + Icon: WorkspaceIcon, + name: "Workspaces", + description: "Manage all workspaces on this instance.", + href: `/workspace/`, + }, + authentication: { + Icon: Lock, + name: "Authentication", + description: "Configure authentication modes.", + href: `/authentication/`, + }, + ai: { + Icon: BrainCog, + name: "Artificial intelligence", + description: "Configure your OpenAI creds.", + href: `/ai/`, + }, + image: { + Icon: Image, + name: "Images in Plane", + description: "Allow third-party image libraries.", + href: `/image/`, + }, +}; diff --git a/apps/admin/core/hooks/use-sidebar-menu/index.ts b/apps/admin/core/hooks/use-sidebar-menu/index.ts new file mode 100644 index 0000000000..0f9e717dc6 --- /dev/null +++ b/apps/admin/core/hooks/use-sidebar-menu/index.ts @@ -0,0 +1,14 @@ +// local imports +import { coreSidebarMenuLinks } from "./core"; +import type { TSidebarMenuItem } from "./types"; + +export function useSidebarMenu(): TSidebarMenuItem[] { + return [ + coreSidebarMenuLinks.general, + coreSidebarMenuLinks.email, + coreSidebarMenuLinks.authentication, + coreSidebarMenuLinks.workspace, + coreSidebarMenuLinks.ai, + coreSidebarMenuLinks.image, + ]; +} diff --git a/apps/admin/core/hooks/use-sidebar-menu/types.ts b/apps/admin/core/hooks/use-sidebar-menu/types.ts new file mode 100644 index 0000000000..d7a49a50d7 --- /dev/null +++ b/apps/admin/core/hooks/use-sidebar-menu/types.ts @@ -0,0 +1,8 @@ +import type { LucideIcon } from "lucide-react"; + +export type TSidebarMenuItem = { + Icon: LucideIcon | React.ComponentType<{ className?: string }>; + name: string; + description: string; + href: string; +}; diff --git a/apps/admin/app/providers.tsx b/apps/admin/core/providers/core.tsx similarity index 70% rename from apps/admin/app/providers.tsx rename to apps/admin/core/providers/core.tsx index 0406cec092..d06d8f3f4a 100644 --- a/apps/admin/app/providers.tsx +++ b/apps/admin/core/providers/core.tsx @@ -1,10 +1,11 @@ import { ThemeProvider } from "next-themes"; import { SWRConfig } from "swr"; import { AppProgressBar } from "@/lib/b-progress"; -import { InstanceProvider } from "./(all)/instance.provider"; -import { StoreProvider } from "./(all)/store.provider"; -import { ToastWithTheme } from "./(all)/toast"; -import { UserProvider } from "./(all)/user.provider"; +// local imports +import { ToastWithTheme } from "./toast"; +import { StoreProvider } from "./store.provider"; +import { InstanceProvider } from "./instance.provider"; +import { UserProvider } from "./user.provider"; const DEFAULT_SWR_CONFIG = { refreshWhenHidden: false, @@ -15,7 +16,7 @@ const DEFAULT_SWR_CONFIG = { errorRetryCount: 3, }; -export function AppProviders({ children }: { children: React.ReactNode }) { +export function CoreProviders({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/admin/core/providers/extended.tsx b/apps/admin/core/providers/extended.tsx new file mode 100644 index 0000000000..60f36cbe47 --- /dev/null +++ b/apps/admin/core/providers/extended.tsx @@ -0,0 +1,3 @@ +export function ExtendedProviders({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/core/providers/index.tsx b/apps/admin/core/providers/index.tsx new file mode 100644 index 0000000000..c0447b5bce --- /dev/null +++ b/apps/admin/core/providers/index.tsx @@ -0,0 +1,10 @@ +import { CoreProviders } from "./core"; +import { ExtendedProviders } from "./extended"; + +export function AppProviders({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/admin/app/(all)/instance.provider.tsx b/apps/admin/core/providers/instance.provider.tsx similarity index 100% rename from apps/admin/app/(all)/instance.provider.tsx rename to apps/admin/core/providers/instance.provider.tsx diff --git a/apps/admin/app/(all)/store.provider.tsx b/apps/admin/core/providers/store.provider.tsx similarity index 100% rename from apps/admin/app/(all)/store.provider.tsx rename to apps/admin/core/providers/store.provider.tsx diff --git a/apps/admin/app/(all)/toast.tsx b/apps/admin/core/providers/toast.tsx similarity index 100% rename from apps/admin/app/(all)/toast.tsx rename to apps/admin/core/providers/toast.tsx diff --git a/apps/admin/app/(all)/user.provider.tsx b/apps/admin/core/providers/user.provider.tsx similarity index 100% rename from apps/admin/app/(all)/user.provider.tsx rename to apps/admin/core/providers/user.provider.tsx diff --git a/apps/admin/ee/components/authentication/authentication-modes.tsx b/apps/admin/ee/components/authentication/authentication-modes.tsx deleted file mode 100644 index 4e3b05a522..0000000000 --- a/apps/admin/ee/components/authentication/authentication-modes.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "ce/components/authentication/authentication-modes"; diff --git a/apps/admin/ee/components/authentication/index.ts b/apps/admin/ee/components/authentication/index.ts deleted file mode 100644 index d2aa748557..0000000000 --- a/apps/admin/ee/components/authentication/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./authentication-modes"; diff --git a/apps/admin/ee/components/common/index.ts b/apps/admin/ee/components/common/index.ts deleted file mode 100644 index 60441ee25b..0000000000 --- a/apps/admin/ee/components/common/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "ce/components/common"; diff --git a/apps/admin/package.json b/apps/admin/package.json index 6f6f768824..25f0076f63 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -19,6 +19,9 @@ }, "dependencies": { "@bprogress/core": "catalog:", + "@fontsource-variable/inter": "5.2.8", + "@fontsource/ibm-plex-mono": "5.2.7", + "@fontsource/material-symbols-rounded": "5.2.30", "@headlessui/react": "^1.7.19", "@plane/constants": "workspace:*", "@plane/hooks": "workspace:*", diff --git a/apps/admin/styles/globals.css b/apps/admin/styles/globals.css index 12417088e6..7f7f2483a5 100644 --- a/apps/admin/styles/globals.css +++ b/apps/admin/styles/globals.css @@ -11,12 +11,12 @@ /* progress bar */ .progress-bar { fill: currentColor; - color: rgba(var(--color-sidebar-background-100)); + color: var(--background-color-surface-1); } /* Progress Bar Styles */ :root { - --bprogress-color: rgb(var(--color-primary-100)) !important; + --bprogress-color: var(--background-color-accent-primary); --bprogress-height: 2.5px !important; } @@ -27,8 +27,8 @@ .bprogress .bar { background: linear-gradient( 90deg, - rgba(var(--color-primary-100), 0.8) 0%, - rgba(var(--color-primary-100), 1) 100% + --alpha(var(--background-color-accent-primary) / 80%) 0%, + --alpha(var(--background-color-accent-primary) / 100%) 100% ) !important; will-change: width, opacity; } @@ -36,7 +36,7 @@ .bprogress .peg { display: block; box-shadow: - 0 0 8px rgba(var(--color-primary-100), 0.6), - 0 0 4px rgba(var(--color-primary-100), 0.4) !important; + 0 0 8px --alpha(var(--background-color-accent-primary) / 60%), + 0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important; will-change: transform, opacity; } diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py index b22438ad51..c9a0a31c89 100644 --- a/apps/api/plane/api/serializers/project.py +++ b/apps/api/plane/api/serializers/project.py @@ -3,13 +3,7 @@ import random from rest_framework import serializers # Module imports -from plane.db.models import ( - Project, - ProjectIdentifier, - WorkspaceMember, - State, - Estimate, -) +from plane.db.models import Project, ProjectIdentifier, WorkspaceMember, State, Estimate from plane.utils.content_validator import ( validate_html_content, @@ -123,6 +117,7 @@ class ProjectCreateSerializer(BaseSerializer): def create(self, validated_data): identifier = validated_data.get("identifier", "").strip().upper() + if identifier == "": raise serializers.ValidationError(detail="Project Identifier is required") diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py index fa735f557d..fbf6790516 100644 --- a/apps/api/plane/api/views/project.py +++ b/apps/api/plane/api/views/project.py @@ -210,7 +210,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView): """ try: workspace = Workspace.objects.get(slug=slug) + serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id}) + if serializer.is_valid(): serializer.save() diff --git a/apps/api/plane/app/serializers/project.py b/apps/api/plane/app/serializers/project.py index d7c458d40d..b8a4136c96 100644 --- a/apps/api/plane/app/serializers/project.py +++ b/apps/api/plane/app/serializers/project.py @@ -13,7 +13,7 @@ from plane.db.models import ( ProjectIdentifier, DeployBoard, ProjectPublicMember, - IssueSequence + IssueSequence, ) from plane.utils.content_validator import ( validate_html_content, diff --git a/apps/api/plane/app/serializers/user.py b/apps/api/plane/app/serializers/user.py index 670667a853..5910178573 100644 --- a/apps/api/plane/app/serializers/user.py +++ b/apps/api/plane/app/serializers/user.py @@ -78,6 +78,7 @@ class UserMeSerializer(BaseSerializer): "is_password_autoset", "is_email_verified", "last_login_medium", + "last_login_time", ] read_only_fields = fields diff --git a/apps/api/plane/app/views/workspace/base.py b/apps/api/plane/app/views/workspace/base.py index c27b7adbb2..f96b002fd2 100644 --- a/apps/api/plane/app/views/workspace/base.py +++ b/apps/api/plane/app/views/workspace/base.py @@ -42,7 +42,9 @@ from plane.app.permissions import ROLE, allow_permission from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS from plane.license.utils.instance_value import get_configuration_value from plane.bgtasks.workspace_seed_task import workspace_seed +from plane.bgtasks.event_tracking_task import track_event from plane.utils.url import contains_url +from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED class WorkSpaceViewSet(BaseViewSet): @@ -131,6 +133,20 @@ class WorkSpaceViewSet(BaseViewSet): workspace_seed.delay(serializer.data["id"]) + track_event.delay( + user_id=request.user.id, + event_name=WORKSPACE_CREATED, + slug=data["slug"], + event_properties={ + "user_id": request.user.id, + "workspace_id": data["id"], + "workspace_slug": data["slug"], + "role": "owner", + "workspace_name": data["name"], + "created_at": data["created_at"], + }, + ) + return Response(data, status=status.HTTP_201_CREATED) return Response( [serializer.errors[error][0] for error in serializer.errors], @@ -164,6 +180,19 @@ class WorkSpaceViewSet(BaseViewSet): # Get the workspace workspace = self.get_object() self.remove_last_workspace_ids_from_user_settings(workspace.id) + track_event.delay( + user_id=request.user.id, + event_name=WORKSPACE_DELETED, + slug=workspace.slug, + event_properties={ + "user_id": request.user.id, + "workspace_id": workspace.id, + "workspace_slug": workspace.slug, + "role": "owner", + "workspace_name": workspace.name, + "deleted_at": str(timezone.now().isoformat()), + }, + ) return super().destroy(request, *args, **kwargs) diff --git a/apps/api/plane/app/views/workspace/invite.py b/apps/api/plane/app/views/workspace/invite.py index 48bcf7eba3..c237e0860f 100644 --- a/apps/api/plane/app/views/workspace/invite.py +++ b/apps/api/plane/app/views/workspace/invite.py @@ -21,12 +21,13 @@ from plane.app.serializers import ( WorkSpaceMemberSerializer, ) from plane.app.views.base import BaseAPIView -from plane.bgtasks.event_tracking_task import workspace_invite_event +from plane.bgtasks.event_tracking_task import track_event from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite from plane.utils.cache import invalidate_cache, invalidate_cache_directly from plane.utils.host import base_host from plane.utils.ip_address import get_client_ip +from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE from .. import BaseViewSet @@ -121,6 +122,19 @@ class WorkspaceInvitationsViewset(BaseViewSet): current_site, request.user.email, ) + track_event.delay( + user_id=request.user.id, + event_name=USER_INVITED_TO_WORKSPACE, + slug=slug, + event_properties={ + "user_id": request.user.id, + "workspace_id": workspace.id, + "workspace_slug": workspace.slug, + "invitee_role": invitation.role, + "invited_at": str(timezone.now()), + "invitee_email": invitation.email, + }, + ) return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK) @@ -186,20 +200,22 @@ class WorkspaceJoinEndpoint(BaseAPIView): # Set the user last_workspace_id to the accepted workspace user.last_workspace_id = workspace_invite.workspace.id user.save() + track_event.delay( + user_id=user.id, + event_name=USER_JOINED_WORKSPACE, + slug=slug, + event_properties={ + "user_id": user.id, + "workspace_id": workspace_invite.workspace.id, + "workspace_slug": workspace_invite.workspace.slug, + "role": workspace_invite.role, + "joined_at": str(timezone.now()), + }, + ) # Delete the invitation workspace_invite.delete() - # Send event - workspace_invite_event.delay( - user=user.id if user is not None else None, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=get_client_ip(request=request), - event_name="MEMBER_ACCEPTED", - accepted_from="EMAIL", - ) - return Response( {"message": "Workspace Invitation Accepted"}, status=status.HTTP_200_OK, @@ -252,6 +268,20 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet): is_active=True, role=invitation.role ) + # Track event + track_event.delay( + user_id=request.user.id, + event_name=USER_JOINED_WORKSPACE, + slug=invitation.workspace.slug, + event_properties={ + "user_id": request.user.id, + "workspace_id": invitation.workspace.id, + "workspace_slug": invitation.workspace.slug, + "role": invitation.role, + "joined_at": str(timezone.now()), + }, + ) + # Bulk create the user for all the workspaces WorkspaceMember.objects.bulk_create( [ diff --git a/apps/api/plane/authentication/adapter/base.py b/apps/api/plane/authentication/adapter/base.py index d01f3f10b2..baae95453b 100644 --- a/apps/api/plane/authentication/adapter/base.py +++ b/apps/api/plane/authentication/adapter/base.py @@ -21,6 +21,7 @@ from plane.bgtasks.user_activation_email_task import user_activation_email from plane.utils.host import base_host from plane.utils.ip_address import get_client_ip from plane.utils.exception_logger import log_exception +from plane.settings.storage import S3Storage class Adapter: @@ -90,9 +91,9 @@ class Adapter: """Check if sign up is enabled or not and raise exception if not enabled""" # Get configuration value - (ENABLE_SIGNUP,) = get_configuration_value( - [{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}] - ) + (ENABLE_SIGNUP,) = get_configuration_value([ + {"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")} + ]) # Check if sign up is disabled and invite is present or not if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists(): @@ -108,6 +109,20 @@ class Adapter: def get_avatar_download_headers(self): return {} + def check_sync_enabled(self): + """Check if sync is enabled for the provider""" + provider_config_map = { + "google": "ENABLE_GOOGLE_SYNC", + "github": "ENABLE_GITHUB_SYNC", + "gitlab": "ENABLE_GITLAB_SYNC", + "gitea": "ENABLE_GITEA_SYNC", + } + config_key = provider_config_map.get(self.provider) + if config_key: + (enabled,) = get_configuration_value([{"key": config_key, "default": os.environ.get(config_key, "0")}]) + return enabled == "1" + return False + def download_and_upload_avatar(self, avatar_url, user): """ Downloads avatar from OAuth provider and uploads to our storage. @@ -156,9 +171,6 @@ class Adapter: # Generate unique filename filename = f"{uuid.uuid4().hex}-user-avatar.{extension}" - # Upload to S3/MinIO storage - from plane.settings.storage import S3Storage - storage = S3Storage(request=self.request) # Create file-like object @@ -208,6 +220,59 @@ class Adapter: user.save() return user + def delete_old_avatar(self, user): + """Delete the old avatar if it exists""" + try: + if user.avatar_asset: + asset = FileAsset.objects.get(pk=user.avatar_asset_id) + storage = S3Storage(request=self.request) + storage.delete_files(object_names=[asset.asset.name]) + + # Delete the user avatar + asset.delete() + user.avatar_asset = None + user.avatar = "" + user.save() + return + except FileAsset.DoesNotExist: + pass + except Exception as e: + log_exception(e) + return + + def sync_user_data(self, user): + # Update user details + first_name = self.user_data.get("user", {}).get("first_name", "") + last_name = self.user_data.get("user", {}).get("last_name", "") + user.first_name = first_name if first_name else "" + user.last_name = last_name if last_name else "" + + # Get email + email = self.user_data.get("email") + + # Get display name + display_name = self.user_data.get("user", {}).get("display_name") + # If display name is not provided, generate a random display name + if not display_name: + display_name = User.get_display_name(email) + + # Set display name + user.display_name = display_name + + # Download and upload avatar only if the avatar is different from the one in the storage + avatar = self.user_data.get("user", {}).get("avatar", "") + # Delete the old avatar if it exists + self.delete_old_avatar(user=user) + avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user) + if avatar_asset: + user.avatar_asset = avatar_asset + # If avatar upload fails, set the avatar to the original URL + else: + user.avatar = avatar + + user.save() + return user + def complete_login_or_signup(self): # Get email email = self.user_data.get("email") @@ -255,6 +320,7 @@ class Adapter: avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user) if avatar_asset: user.avatar_asset = avatar_asset + user.avatar = avatar # If avatar upload fails, set the avatar to the original URL else: user.avatar = avatar @@ -262,6 +328,10 @@ class Adapter: # Create profile Profile.objects.create(user=user) + # Check if IDP sync is enabled and user is not signing up + if self.check_sync_enabled() and not is_signup: + user = self.sync_user_data(user=user) + # Save user data user = self.save_user_data(user=user) diff --git a/apps/api/plane/authentication/utils/workspace_project_join.py b/apps/api/plane/authentication/utils/workspace_project_join.py index bd5ad8501b..31dae55eb7 100644 --- a/apps/api/plane/authentication/utils/workspace_project_join.py +++ b/apps/api/plane/authentication/utils/workspace_project_join.py @@ -1,3 +1,7 @@ +# Django imports +from django.utils import timezone + +# Module imports from plane.db.models import ( ProjectMember, ProjectMemberInvite, @@ -5,6 +9,8 @@ from plane.db.models import ( WorkspaceMemberInvite, ) from plane.utils.cache import invalidate_cache_directly +from plane.bgtasks.event_tracking_task import track_event +from plane.utils.analytics_events import USER_JOINED_WORKSPACE def process_workspace_project_invitations(user): @@ -25,15 +31,25 @@ def process_workspace_project_invitations(user): ignore_conflicts=True, ) - [ + for workspace_member_invite in workspace_member_invites: invalidate_cache_directly( path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/", url_params=False, user=False, multiple=True, ) - for workspace_member_invite in workspace_member_invites - ] + track_event.delay( + user_id=user.id, + event_name=USER_JOINED_WORKSPACE, + slug=workspace_member_invite.workspace.slug, + event_properties={ + "user_id": user.id, + "workspace_id": workspace_member_invite.workspace.id, + "workspace_slug": workspace_member_invite.workspace.slug, + "role": workspace_member_invite.role, + "joined_at": str(timezone.now().isoformat()), + }, + ) # Check if user has any project invites project_member_invites = ProjectMemberInvite.objects.filter(email=user.email, accepted=True) diff --git a/apps/api/plane/bgtasks/event_tracking_task.py b/apps/api/plane/bgtasks/event_tracking_task.py index 0629db93af..82857fdfe1 100644 --- a/apps/api/plane/bgtasks/event_tracking_task.py +++ b/apps/api/plane/bgtasks/event_tracking_task.py @@ -1,5 +1,7 @@ +import logging import os import uuid +from typing import Dict, Any # third party imports from celery import shared_task @@ -8,6 +10,11 @@ from posthog import Posthog # module imports from plane.license.utils.instance_value import get_configuration_value from plane.utils.exception_logger import log_exception +from plane.db.models import Workspace +from plane.utils.analytics_events import USER_INVITED_TO_WORKSPACE, WORKSPACE_DELETED + + +logger = logging.getLogger("plane.worker") def posthogConfiguration(): @@ -17,7 +24,10 @@ def posthogConfiguration(): "key": "POSTHOG_API_KEY", "default": os.environ.get("POSTHOG_API_KEY", None), }, - {"key": "POSTHOG_HOST", "default": os.environ.get("POSTHOG_HOST", None)}, + { + "key": "POSTHOG_HOST", + "default": os.environ.get("POSTHOG_HOST", None), + }, ] ) if POSTHOG_API_KEY and POSTHOG_HOST: @@ -26,46 +36,42 @@ def posthogConfiguration(): return None, None -@shared_task -def auth_events(user, email, user_agent, ip, event_name, medium, first_time): - try: - POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() +def preprocess_data_properties( + user_id: uuid.UUID, event_name: str, slug: str, data_properties: Dict[str, Any] +) -> Dict[str, Any]: + if event_name == USER_INVITED_TO_WORKSPACE or event_name == WORKSPACE_DELETED: + try: + # Check if the current user is the workspace owner + workspace = Workspace.objects.get(slug=slug) + if str(workspace.owner_id) == str(user_id): + data_properties["role"] = "owner" + else: + data_properties["role"] = "admin" + except Workspace.DoesNotExist: + logger.warning(f"Workspace {slug} does not exist while sending event {event_name} for user {user_id}") + data_properties["role"] = "unknown" - if POSTHOG_API_KEY and POSTHOG_HOST: - posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) - posthog.capture( - email, - event=event_name, - properties={ - "event_id": uuid.uuid4().hex, - "user": {"email": email, "id": str(user)}, - "device_ctx": {"ip": ip, "user_agent": user_agent}, - "medium": medium, - "first_time": first_time, - }, - ) - except Exception as e: - log_exception(e) - return + return data_properties @shared_task -def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from): - try: - POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() +def track_event(user_id: uuid.UUID, event_name: str, slug: str, event_properties: Dict[str, Any]): + POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() - if POSTHOG_API_KEY and POSTHOG_HOST: - posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) - posthog.capture( - email, - event=event_name, - properties={ - "event_id": uuid.uuid4().hex, - "user": {"email": email, "id": str(user)}, - "device_ctx": {"ip": ip, "user_agent": user_agent}, - "accepted_from": accepted_from, - }, - ) + if not (POSTHOG_API_KEY and POSTHOG_HOST): + logger.warning("Event tracking is not configured") + return + + try: + # preprocess the data properties for massaging the payload + # in the correct format for posthog + data_properties = preprocess_data_properties(user_id, event_name, slug, event_properties) + groups = { + "workspace": slug, + } + # track the event using posthog + posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) + posthog.capture(distinct_id=str(user_id), event=event_name, properties=data_properties, groups=groups) except Exception as e: log_exception(e) - return + return False diff --git a/apps/api/plane/bgtasks/export_task.py b/apps/api/plane/bgtasks/export_task.py index 75b5f22659..f77e379108 100644 --- a/apps/api/plane/bgtasks/export_task.py +++ b/apps/api/plane/bgtasks/export_task.py @@ -15,9 +15,10 @@ from django.utils import timezone from django.db.models import Prefetch # Module imports -from plane.db.models import ExporterHistory, Issue, IssueRelation +from plane.db.models import ExporterHistory, Issue, IssueComment, IssueRelation, IssueSubscriber from plane.utils.exception_logger import log_exception -from plane.utils.exporters import Exporter, IssueExportSchema +from plane.utils.porters.exporter import DataExporter +from plane.utils.porters.serializers.issue import IssueExportSerializer def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO: @@ -159,10 +160,16 @@ def issue_export_task( "labels", "issue_cycle__cycle", "issue_module__module", - "issue_comments", "assignees", - "issue_subscribers", "issue_link", + Prefetch( + "issue_subscribers", + queryset=IssueSubscriber.objects.select_related("subscriber"), + ), + Prefetch( + "issue_comments", + queryset=IssueComment.objects.select_related("actor").order_by("created_at"), + ), Prefetch( "issue_relation", queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"), @@ -180,11 +187,7 @@ def issue_export_task( # Create exporter for the specified format try: - exporter = Exporter( - format_type=provider, - schema_class=IssueExportSchema, - options={"list_joiner": ", "}, - ) + exporter = DataExporter(IssueExportSerializer, format_type=provider) except ValueError as e: # Invalid format type exporter_instance = ExporterHistory.objects.get(token=token_id) diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index 8495ac9df4..173ed43854 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -116,6 +116,11 @@ class Project(BaseModel): external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) + def __init__(self, *args, **kwargs): + # Track if timezone is provided, if so, don't override it with the workspace timezone when saving + self.is_timezone_provided = kwargs.get("timezone") is not None + super().__init__(*args, **kwargs) + @property def cover_image_url(self): # Return cover image url @@ -155,7 +160,15 @@ class Project(BaseModel): ordering = ("-created_at",) def save(self, *args, **kwargs): + from plane.db.models import Workspace + self.identifier = self.identifier.strip().upper() + is_creating = self._state.adding + + if is_creating and not self.is_timezone_provided: + workspace = Workspace.objects.get(id=self.workspace_id) + self.timezone = workspace.timezone + return super().save(*args, **kwargs) diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py index 9b5f5ac6b5..2a4f42f8fc 100644 --- a/apps/api/plane/db/models/user.py +++ b/apps/api/plane/db/models/user.py @@ -147,6 +147,11 @@ class User(AbstractBaseUser, PermissionsMixin): return self.cover_image return None + @property + def full_name(self): + """Return user's full name (first + last).""" + return f"{self.first_name} {self.last_name}".strip() + def save(self, *args, **kwargs): self.email = self.email.lower().strip() self.mobile_number = self.mobile_number @@ -167,6 +172,16 @@ class User(AbstractBaseUser, PermissionsMixin): super(User, self).save(*args, **kwargs) + @classmethod + def get_display_name(cls, email): + if not email: + return "".join(random.choice(string.ascii_letters) for _ in range(6)) + return ( + email.split("@")[0] + if len(email.split("@")) == 2 + else "".join(random.choice(string.ascii_letters) for _ in range(6)) + ) + class Profile(TimeAuditModel): SUNDAY = 0 diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py index 01afa62374..4ebf6c58e8 100644 --- a/apps/api/plane/settings/storage.py +++ b/apps/api/plane/settings/storage.py @@ -187,3 +187,15 @@ class S3Storage(S3Boto3Storage): except ClientError as e: log_exception(e) return False + + def delete_files(self, object_names): + """Delete an S3 object""" + try: + self.s3_client.delete_objects( + Bucket=self.aws_storage_bucket_name, + Delete={"Objects": [{"Key": object_name} for object_name in object_names]}, + ) + return True + except ClientError as e: + log_exception(e) + return False diff --git a/apps/api/plane/utils/analytics_events.py b/apps/api/plane/utils/analytics_events.py new file mode 100644 index 0000000000..7fa8af9493 --- /dev/null +++ b/apps/api/plane/utils/analytics_events.py @@ -0,0 +1,4 @@ +USER_JOINED_WORKSPACE = "user_joined_workspace" +USER_INVITED_TO_WORKSPACE = "user_invited_to_workspace" +WORKSPACE_CREATED = "workspace_created" +WORKSPACE_DELETED = "workspace_deleted" diff --git a/apps/api/plane/utils/instance_config_variables/core.py b/apps/api/plane/utils/instance_config_variables/core.py index cf8d8d41fb..4f4833207a 100644 --- a/apps/api/plane/utils/instance_config_variables/core.py +++ b/apps/api/plane/utils/instance_config_variables/core.py @@ -44,6 +44,12 @@ google_config_variables = [ "category": "GOOGLE", "is_encrypted": True, }, + { + "key": "ENABLE_GOOGLE_SYNC", + "value": os.environ.get("ENABLE_GOOGLE_SYNC", "0"), + "category": "GOOGLE", + "is_encrypted": False, + }, ] github_config_variables = [ @@ -65,6 +71,12 @@ github_config_variables = [ "category": "GITHUB", "is_encrypted": False, }, + { + "key": "ENABLE_GITHUB_SYNC", + "value": os.environ.get("ENABLE_GITHUB_SYNC", "0"), + "category": "GITHUB", + "is_encrypted": False, + }, ] @@ -87,6 +99,12 @@ gitlab_config_variables = [ "category": "GITLAB", "is_encrypted": True, }, + { + "key": "ENABLE_GITLAB_SYNC", + "value": os.environ.get("ENABLE_GITLAB_SYNC", "0"), + "category": "GITLAB", + "is_encrypted": False, + }, ] gitea_config_variables = [ @@ -114,6 +132,12 @@ gitea_config_variables = [ "category": "GITEA", "is_encrypted": True, }, + { + "key": "ENABLE_GITEA_SYNC", + "value": os.environ.get("ENABLE_GITEA_SYNC", "0"), + "category": "GITEA", + "is_encrypted": False, + }, ] smtp_config_variables = [ diff --git a/apps/api/plane/utils/porters/__init__.py b/apps/api/plane/utils/porters/__init__.py new file mode 100644 index 0000000000..cd411ff724 --- /dev/null +++ b/apps/api/plane/utils/porters/__init__.py @@ -0,0 +1,15 @@ +from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter +from .exporter import DataExporter +from .serializers import IssueExportSerializer + +__all__ = [ + # Formatters + "BaseFormatter", + "CSVFormatter", + "JSONFormatter", + "XLSXFormatter", + # Exporters + "DataExporter", + # Export Serializers + "IssueExportSerializer", +] diff --git a/apps/api/plane/utils/porters/exporter.py b/apps/api/plane/utils/porters/exporter.py new file mode 100644 index 0000000000..3b55d4d984 --- /dev/null +++ b/apps/api/plane/utils/porters/exporter.py @@ -0,0 +1,103 @@ +from typing import Dict, List, Union +from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter + + +class DataExporter: + """ + Export data using DRF serializers with built-in format support. + + Usage: + # New simplified interface + exporter = DataExporter(BookSerializer, format_type='csv') + filename, content = exporter.export('books_export', queryset) + + # Legacy interface (still supported) + exporter = DataExporter(BookSerializer) + csv_string = exporter.to_string(queryset, CSVFormatter()) + """ + + # Available formatters + FORMATTERS = { + "csv": CSVFormatter, + "json": JSONFormatter, + "xlsx": XLSXFormatter, + } + + def __init__(self, serializer_class, format_type: str = None, **serializer_kwargs): + """ + Initialize exporter with serializer and optional format type. + + Args: + serializer_class: DRF serializer class to use for data serialization + format_type: Optional format type (csv, json, xlsx). If provided, enables export() method. + **serializer_kwargs: Additional kwargs to pass to serializer + """ + self.serializer_class = serializer_class + self.serializer_kwargs = serializer_kwargs + self.format_type = format_type + self.formatter = None + + if format_type: + if format_type not in self.FORMATTERS: + raise ValueError(f"Unsupported format: {format_type}. Available: {list(self.FORMATTERS.keys())}") + # Create formatter with default options + self.formatter = self._create_formatter(format_type) + + def _create_formatter(self, format_type: str) -> BaseFormatter: + """Create formatter instance with appropriate options.""" + formatter_class = self.FORMATTERS[format_type] + + # Apply format-specific options + if format_type == "xlsx": + return formatter_class(list_joiner=", ") + else: + return formatter_class() + + def serialize(self, queryset) -> List[Dict]: + """QuerySet → list of dicts""" + serializer = self.serializer_class( + queryset, + many=True, + **self.serializer_kwargs + ) + return serializer.data + + def export(self, filename: str, queryset) -> tuple[str, Union[str, bytes]]: + """ + Export queryset to file with configured format. + + Args: + filename: Base filename (without extension) + queryset: Django QuerySet to export + + Returns: + Tuple of (filename_with_extension, content) + + Raises: + ValueError: If format_type was not provided during initialization + """ + if not self.formatter: + raise ValueError("format_type must be provided during initialization to use export() method") + + data = self.serialize(queryset) + content = self.formatter.encode(data) + full_filename = f"{filename}.{self.formatter.extension}" + + return full_filename, content + + def to_string(self, queryset, formatter: BaseFormatter) -> Union[str, bytes]: + """Export to formatted string (legacy interface)""" + data = self.serialize(queryset) + return formatter.encode(data) + + def to_file(self, queryset, filepath: str, formatter: BaseFormatter) -> str: + """Export to file (legacy interface)""" + content = self.to_string(queryset, formatter) + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + return filepath + + @classmethod + def get_available_formats(cls) -> List[str]: + """Get list of available export formats.""" + return list(cls.FORMATTERS.keys()) diff --git a/apps/api/plane/utils/porters/formatters.py b/apps/api/plane/utils/porters/formatters.py new file mode 100644 index 0000000000..e130f73540 --- /dev/null +++ b/apps/api/plane/utils/porters/formatters.py @@ -0,0 +1,265 @@ +""" +Import/Export System with Pluggable Formatters + +Exporter: QuerySet → Serializer → Formatter → File/String +Importer: File/String → Formatter → Serializer → Models +""" + +import csv +import json +from abc import ABC, abstractmethod +from io import BytesIO, StringIO +from typing import Any, Dict, List, Union + +from openpyxl import Workbook, load_workbook + + +class BaseFormatter(ABC): + @abstractmethod + def encode(self, data: List[Dict]) -> Union[str, bytes]: + """Data → formatted string/bytes""" + pass + + @abstractmethod + def decode(self, content: Union[str, bytes]) -> List[Dict]: + """Formatted string/bytes → data""" + pass + + @property + @abstractmethod + def extension(self) -> str: + pass + + +class JSONFormatter(BaseFormatter): + def __init__(self, indent: int = 2): + self.indent = indent + + def encode(self, data: List[Dict]) -> str: + return json.dumps(data, indent=self.indent, default=str) + + def decode(self, content: str) -> List[Dict]: + return json.loads(content) + + @property + def extension(self) -> str: + return "json" + + +class CSVFormatter(BaseFormatter): + def __init__(self, flatten: bool = True, delimiter: str = ",", prettify_headers: bool = True): + """ + Args: + flatten: Whether to flatten nested dicts. + delimiter: CSV delimiter character. + prettify_headers: If True, transforms 'created_by_name' → 'Created By Name'. + """ + self.flatten = flatten + self.delimiter = delimiter + self.prettify_headers = prettify_headers + + def _prettify_header(self, header: str) -> str: + """Transform 'created_by_name' → 'Created By Name'""" + return header.replace("_", " ").title() + + def _normalize_header(self, header: str) -> str: + """Transform 'Display Name' → 'display_name' (reverse of prettify)""" + return header.strip().lower().replace(" ", "_") + + def _flatten(self, row: Dict, parent_key: str = "") -> Dict: + items = {} + for key, value in row.items(): + new_key = f"{parent_key}__{key}" if parent_key else key + if isinstance(value, dict): + items.update(self._flatten(value, new_key)) + elif isinstance(value, list): + items[new_key] = json.dumps(value) + else: + items[new_key] = value + return items + + def _unflatten(self, row: Dict) -> Dict: + result = {} + for key, value in row.items(): + parts = key.split("__") + current = result + for part in parts[:-1]: + current = current.setdefault(part, {}) + + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, (list, dict)): + value = parsed + except (json.JSONDecodeError, TypeError): + pass + + current[parts[-1]] = value + return result + + def encode(self, data: List[Dict]) -> str: + if not data: + return "" + + if self.flatten: + data = [self._flatten(row) for row in data] + + # Collect all unique field names in order + fieldnames = [] + for row in data: + for key in row.keys(): + if key not in fieldnames: + fieldnames.append(key) + + output = StringIO() + + if self.prettify_headers: + # Create header mapping: original_key → Pretty Header + header_map = {key: self._prettify_header(key) for key in fieldnames} + pretty_headers = [header_map[key] for key in fieldnames] + + # Write pretty headers manually, then write data rows + writer = csv.writer(output, delimiter=self.delimiter) + writer.writerow(pretty_headers) + + # Write data rows in the same field order + for row in data: + writer.writerow([row.get(key, "") for key in fieldnames]) + else: + writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=self.delimiter) + writer.writeheader() + writer.writerows(data) + + return output.getvalue() + + def decode(self, content: str, normalize_headers: bool = True) -> List[Dict]: + """ + Decode CSV content to list of dicts. + + Args: + content: CSV string + normalize_headers: If True, converts 'Display Name' → 'display_name' + """ + rows = list(csv.DictReader(StringIO(content), delimiter=self.delimiter)) + + # Normalize headers: 'Email' → 'email', 'Display Name' → 'display_name' + if normalize_headers: + rows = [{self._normalize_header(k): v for k, v in row.items()} for row in rows] + + if self.flatten: + rows = [self._unflatten(row) for row in rows] + + return rows + + @property + def extension(self) -> str: + return "csv" + + +class XLSXFormatter(BaseFormatter): + """Formatter for XLSX (Excel) files using openpyxl.""" + + def __init__(self, prettify_headers: bool = True, list_joiner: str = ", "): + """ + Args: + prettify_headers: If True, transforms 'created_by_name' → 'Created By Name'. + list_joiner: String to join list values (default: ", "). + """ + self.prettify_headers = prettify_headers + self.list_joiner = list_joiner + + def _prettify_header(self, header: str) -> str: + """Transform 'created_by_name' → 'Created By Name'""" + return header.replace("_", " ").title() + + def _normalize_header(self, header: str) -> str: + """Transform 'Display Name' → 'display_name' (reverse of prettify)""" + return header.strip().lower().replace(" ", "_") + + def _format_value(self, value: Any) -> Any: + """Format a value for XLSX cell.""" + if value is None: + return "" + if isinstance(value, list): + return self.list_joiner.join(str(v) for v in value) + if isinstance(value, dict): + return json.dumps(value) + return value + + def encode(self, data: List[Dict]) -> bytes: + """Encode data to XLSX bytes.""" + wb = Workbook() + ws = wb.active + + if not data: + # Return empty workbook + output = BytesIO() + wb.save(output) + output.seek(0) + return output.getvalue() + + # Collect all unique field names in order + fieldnames = [] + for row in data: + for key in row.keys(): + if key not in fieldnames: + fieldnames.append(key) + + # Write header row + if self.prettify_headers: + headers = [self._prettify_header(key) for key in fieldnames] + else: + headers = fieldnames + ws.append(headers) + + # Write data rows + for row in data: + ws.append([self._format_value(row.get(key, "")) for key in fieldnames]) + + output = BytesIO() + wb.save(output) + output.seek(0) + return output.getvalue() + + def decode(self, content: bytes, normalize_headers: bool = True) -> List[Dict]: + """ + Decode XLSX bytes to list of dicts. + + Args: + content: XLSX file bytes + normalize_headers: If True, converts 'Display Name' → 'display_name' + """ + wb = load_workbook(filename=BytesIO(content), read_only=True, data_only=True) + ws = wb.active + + rows = list(ws.iter_rows(values_only=True)) + if not rows: + return [] + + # First row is headers + headers = list(rows[0]) + if normalize_headers: + headers = [self._normalize_header(str(h)) if h else "" for h in headers] + + # Convert remaining rows to dicts + result = [] + for row in rows[1:]: + row_dict = {} + for i, value in enumerate(row): + if i < len(headers) and headers[i]: + # Try to parse JSON strings back to lists/dicts + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, (list, dict)): + value = parsed + except (json.JSONDecodeError, TypeError): + pass + row_dict[headers[i]] = value + result.append(row_dict) + + return result + + @property + def extension(self) -> str: + return "xlsx" diff --git a/apps/api/plane/utils/porters/serializers/__init__.py b/apps/api/plane/utils/porters/serializers/__init__.py new file mode 100644 index 0000000000..a52e98d6dd --- /dev/null +++ b/apps/api/plane/utils/porters/serializers/__init__.py @@ -0,0 +1,6 @@ +from .issue import IssueExportSerializer + +__all__ = [ + # Export Serializers + "IssueExportSerializer", +] diff --git a/apps/api/plane/utils/porters/serializers/issue.py b/apps/api/plane/utils/porters/serializers/issue.py new file mode 100644 index 0000000000..94c6f065ae --- /dev/null +++ b/apps/api/plane/utils/porters/serializers/issue.py @@ -0,0 +1,141 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from plane.app.serializers import IssueSerializer + + +class IssueExportSerializer(IssueSerializer): + """ + Export-optimized serializer that extends IssueSerializer with human-readable fields. + + Converts UUIDs to readable values for CSV/JSON export. + """ + + identifier = serializers.SerializerMethodField() + project_name = serializers.CharField(source='project.name', read_only=True, default="") + project_identifier = serializers.CharField(source='project.identifier', read_only=True, default="") + state_name = serializers.CharField(source='state.name', read_only=True, default="") + created_by_name = serializers.CharField(source='created_by.full_name', read_only=True, default="") + + assignees = serializers.SerializerMethodField() + parent = serializers.SerializerMethodField() + labels = serializers.SerializerMethodField() + cycles = serializers.SerializerMethodField() + modules = serializers.SerializerMethodField() + comments = serializers.SerializerMethodField() + estimate = serializers.SerializerMethodField() + links = serializers.SerializerMethodField() + relations = serializers.SerializerMethodField() + subscribers = serializers.SerializerMethodField() + + class Meta(IssueSerializer.Meta): + fields = [ + "project_name", + "project_identifier", + "parent", + "identifier", + "sequence_id", + "name", + "state_name", + "priority", + "assignees", + "subscribers", + "created_by_name", + "start_date", + "target_date", + "completed_at", + "created_at", + "updated_at", + "archived_at", + "estimate", + "labels", + "cycles", + "modules", + "links", + "relations", + "comments", + "sub_issues_count", + "link_count", + "attachment_count", + "is_draft", + ] + + def get_identifier(self, obj): + return f"{obj.project.identifier}-{obj.sequence_id}" + + def get_assignees(self, obj): + return [u.full_name for u in obj.assignees.all() if u.is_active] + + def get_subscribers(self, obj): + """Return list of subscriber names.""" + return [sub.subscriber.full_name for sub in obj.issue_subscribers.all() if sub.subscriber] + + def get_parent(self, obj): + if not obj.parent: + return "" + return f"{obj.parent.project.identifier}-{obj.parent.sequence_id}" + + def get_labels(self, obj): + return [ + il.label.name + for il in obj.label_issue.all() + if il.deleted_at is None + ] + + def get_cycles(self, obj): + return [ic.cycle.name for ic in obj.issue_cycle.all()] + + def get_modules(self, obj): + return [im.module.name for im in obj.issue_module.all()] + + def get_estimate(self, obj): + """Return estimate point value.""" + if obj.estimate_point: + return obj.estimate_point.value if hasattr(obj.estimate_point, 'value') else str(obj.estimate_point) + return "" + + def get_links(self, obj): + """Return list of issue links with titles.""" + return [ + { + "url": link.url, + "title": link.title if link.title else link.url, + } + for link in obj.issue_link.all() + ] + + def get_relations(self, obj): + """Return list of related issues.""" + relations = [] + + # Outgoing relations (this issue relates to others) + for rel in obj.issue_relation.all(): + if rel.related_issue: + relations.append({ + "type": rel.relation_type if hasattr(rel, 'relation_type') else "related", + "issue": f"{rel.related_issue.project.identifier}-{rel.related_issue.sequence_id}", + "direction": "outgoing" + }) + + # Incoming relations (other issues relate to this one) + for rel in obj.issue_related.all(): + if rel.issue: + relations.append({ + "type": rel.relation_type if hasattr(rel, 'relation_type') else "related", + "issue": f"{rel.issue.project.identifier}-{rel.issue.sequence_id}", + "direction": "incoming" + }) + + return relations + + def get_comments(self, obj): + """Return list of comments with author and timestamp.""" + return [ + { + "comment": comment.comment_stripped if hasattr(comment, 'comment_stripped') else comment.comment_html, + "created_by": comment.actor.full_name if comment.actor else "", + "created_at": comment.created_at.strftime("%Y-%m-%d %H:%M:%S") if comment.created_at else "", + } + for comment in obj.issue_comments.all() + ] diff --git a/apps/space/app/error.tsx b/apps/space/app/error.tsx index 5f3ecace49..35267ad5cb 100644 --- a/apps/space/app/error.tsx +++ b/apps/space/app/error.tsx @@ -7,21 +7,21 @@ function ErrorPage() { }; return ( -
+

Yikes! That doesn{"'"}t look good.

That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more details, please write to{" "} - + support@plane.so {" "} or on our{" "} Discord diff --git a/apps/space/app/issues/[anchor]/layout.tsx b/apps/space/app/issues/[anchor]/layout.tsx index e32b008a24..79f2f7e3ea 100644 --- a/apps/space/app/issues/[anchor]/layout.tsx +++ b/apps/space/app/issues/[anchor]/layout.tsx @@ -114,7 +114,7 @@ function IssuesLayout(props: Route.ComponentProps) { if (!publishSettings && !error) { return ( -

+
); diff --git a/apps/space/app/not-found.tsx b/apps/space/app/not-found.tsx index 411859ac04..2ae119c39e 100644 --- a/apps/space/app/not-found.tsx +++ b/apps/space/app/not-found.tsx @@ -3,9 +3,9 @@ import SomethingWentWrongImage from "@/app/assets/something-went-wrong.svg?url"; function NotFound() { return ( -
+
-
+
Something went wrong
diff --git a/apps/space/app/page.tsx b/apps/space/app/page.tsx index 4b473b566b..6f58126ed1 100644 --- a/apps/space/app/page.tsx +++ b/apps/space/app/page.tsx @@ -29,7 +29,7 @@ const HomePage = observer(function HomePage() { if (isInitializing) return ( -
+
); @@ -37,7 +37,7 @@ const HomePage = observer(function HomePage() { if (currentUser && isAuthenticated) { if (nextPath && isValidNextPath(nextPath)) { return ( -
+
); diff --git a/apps/space/app/root.tsx b/apps/space/app/root.tsx index a7f04af7d6..ca0651042e 100644 --- a/apps/space/app/root.tsx +++ b/apps/space/app/root.tsx @@ -13,6 +13,11 @@ import type { Route } from "./+types/root"; // local imports import ErrorPage from "./error"; import { AppProviders } from "./providers"; +// fonts +import "@fontsource-variable/inter"; +import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url"; +import "@fontsource/material-symbols-rounded"; +import "@fontsource/ibm-plex-mono"; const APP_TITLE = "Plane Publish | Make your Plane boards public with one-click"; const APP_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so"; @@ -24,6 +29,13 @@ export const links: Route.LinksFunction = () => [ { rel: "shortcut icon", href: faviconIco }, { rel: "manifest", href: siteWebmanifest }, { rel: "stylesheet", href: globalStyles }, + { + rel: "preload", + href: interVariableWoff2, + as: "font", + type: "font/woff2", + crossOrigin: "anonymous", + }, ]; export const headers: Route.HeadersFunction = () => ({ @@ -72,7 +84,7 @@ export default function Root() { export function HydrateFallback() { return ( -
+
); diff --git a/apps/space/core/components/account/auth-forms/auth-root.tsx b/apps/space/core/components/account/auth-forms/auth-root.tsx index 0308471ecc..e313cce893 100644 --- a/apps/space/core/components/account/auth-forms/auth-root.tsx +++ b/apps/space/core/components/account/auth-forms/auth-root.tsx @@ -1,22 +1,15 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; -import { useTheme } from "next-themes"; // plane imports -import { API_BASE_URL } from "@plane/constants"; import { SitesAuthService } from "@plane/services"; import type { IEmailCheckData } from "@plane/types"; import { OAuthOptions } from "@plane/ui"; -// assets -import GiteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; -import GithubLightLogo from "@/app/assets/logos/github-black.png?url"; -import GithubDarkLogo from "@/app/assets/logos/github-dark.svg?url"; -import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; -import GoogleLogo from "@/app/assets/logos/google-logo.svg?url"; // helpers import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; import { EErrorAlertType, authErrorHandler, EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; // hooks +import { useOAuthConfig } from "@/hooks/oauth"; import { useInstance } from "@/hooks/store/use-instance"; // types import { EAuthModes, EAuthSteps } from "@/types/auth"; @@ -36,7 +29,6 @@ export const AuthRoot = observer(function AuthRoot() { const emailParam = searchParams.get("email") || undefined; const error_code = searchParams.get("error_code") || undefined; const nextPath = searchParams.get("next_path") || undefined; - const next_path = searchParams.get("next_path"); // states const [authMode, setAuthMode] = useState(EAuthModes.SIGN_UP); const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); @@ -44,7 +36,6 @@ export const AuthRoot = observer(function AuthRoot() { const [errorInfo, setErrorInfo] = useState(undefined); const [isPasswordAutoset, setIsPasswordAutoset] = useState(true); // hooks - const { resolvedTheme } = useTheme(); const { config } = useInstance(); useEffect(() => { @@ -87,13 +78,8 @@ export const AuthRoot = observer(function AuthRoot() { const isSMTPConfigured = config?.is_smtp_configured || false; const isMagicLoginEnabled = config?.is_magic_login_enabled || false; const isEmailPasswordEnabled = config?.is_email_password_enabled || false; - const isOAuthEnabled = - (config && - (config?.is_google_enabled || - config?.is_github_enabled || - config?.is_gitlab_enabled || - config?.is_gitea_enabled)) || - false; + const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in"; + const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText); // submit handler- email verification const handleEmailVerification = async (data: IEmailCheckData) => { @@ -153,54 +139,6 @@ export const AuthRoot = observer(function AuthRoot() { }); }; - const content = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in"; - - const OAuthConfig = [ - { - id: "google", - text: `${content} with Google`, - icon: Google Logo, - onClick: () => { - window.location.assign(`${API_BASE_URL}/auth/google/${next_path ? `?next_path=${next_path}` : ``}`); - }, - enabled: config?.is_google_enabled, - }, - { - id: "github", - text: `${content} with GitHub`, - icon: ( - GitHub Logo - ), - onClick: () => { - window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`); - }, - enabled: config?.is_github_enabled, - }, - { - id: "gitlab", - text: `${content} with GitLab`, - icon: GitLab Logo, - onClick: () => { - window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`); - }, - enabled: config?.is_gitlab_enabled, - }, - { - id: "gitea", - text: `${content} with Gitea`, - icon: Gitea Logo, - onClick: () => { - window.location.assign(`${API_BASE_URL}/auth/gitea/${next_path ? `?next_path=${next_path}` : ``}`); - }, - enabled: config?.is_gitea_enabled, - }, - ]; - return (
@@ -208,7 +146,7 @@ export const AuthRoot = observer(function AuthRoot() { setErrorInfo(value)} /> )} - {isOAuthEnabled && } + {isOAuthEnabled && } {authStep === EAuthSteps.EMAIL && } {authStep === EAuthSteps.UNIQUE_CODE && ( diff --git a/apps/space/core/components/account/auth-forms/email.tsx b/apps/space/core/components/account/auth-forms/email.tsx index f407219b0f..46ecea0f0d 100644 --- a/apps/space/core/components/account/auth-forms/email.tsx +++ b/apps/space/core/components/account/auth-forms/email.tsx @@ -83,7 +83,7 @@ export const AuthEmailForm = observer(function AuthEmailForm(props: TAuthEmailFo }} tabIndex={-1} > - + )}
diff --git a/apps/space/core/components/account/auth-forms/password.tsx b/apps/space/core/components/account/auth-forms/password.tsx index cf622f92f7..7d265cee01 100644 --- a/apps/space/core/components/account/auth-forms/password.tsx +++ b/apps/space/core/components/account/auth-forms/password.tsx @@ -132,7 +132,7 @@ export const AuthPasswordForm = observer(function AuthPasswordForm(props: Props) /> {passwordFormData.email.length > 0 && ( )} @@ -158,12 +158,12 @@ export const AuthPasswordForm = observer(function AuthPasswordForm(props: Props) /> {showPassword?.password ? ( handleShowPassword("password")} /> ) : ( handleShowPassword("password")} /> )} @@ -189,12 +189,12 @@ export const AuthPasswordForm = observer(function AuthPasswordForm(props: Props) /> {showPassword?.retypePassword ? ( handleShowPassword("retypePassword")} /> ) : ( handleShowPassword("retypePassword")} /> )} diff --git a/apps/space/core/components/account/auth-forms/unique-code.tsx b/apps/space/core/components/account/auth-forms/unique-code.tsx index fd347bfd94..7e7543e56b 100644 --- a/apps/space/core/components/account/auth-forms/unique-code.tsx +++ b/apps/space/core/components/account/auth-forms/unique-code.tsx @@ -96,7 +96,7 @@ export function AuthUniqueCodeForm(props: TAuthUniqueCodeForm) { /> {uniqueCodeFormData.email.length > 0 && ( )} diff --git a/apps/space/core/components/common/powered-by.tsx b/apps/space/core/components/common/powered-by.tsx index e43c27ca68..824cad483c 100644 --- a/apps/space/core/components/common/powered-by.tsx +++ b/apps/space/core/components/common/powered-by.tsx @@ -15,7 +15,7 @@ export function PoweredBy(props: TPoweredBy) { return ( diff --git a/apps/space/core/components/issues/filters/helpers/dropdown.tsx b/apps/space/core/components/issues/filters/helpers/dropdown.tsx index 032de9f1a0..b4987a9df3 100644 --- a/apps/space/core/components/issues/filters/helpers/dropdown.tsx +++ b/apps/space/core/components/issues/filters/helpers/dropdown.tsx @@ -46,7 +46,7 @@ export function FiltersDropdown(props: Props) { >
+
-
+
diff --git a/apps/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx b/apps/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx index d1f735e974..d9fc8bb1ec 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx @@ -20,11 +20,7 @@ export const BlockReactions = observer(function BlockReactions(props: Props) { if (!canVote && !canReact) return <>; return ( -
+
{canVote && (
+
@@ -50,7 +50,7 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
-
+
{issue.name} diff --git a/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx b/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx index 5fc975b36f..a3dac69440 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx @@ -27,7 +27,7 @@ export const IssueBlockCycle = observer(function IssueBlockCycle({ cycleId, shou >
-
{cycle?.name ?? "No Cycle"}
+
{cycle?.name ?? "No Cycle"}
diff --git a/apps/space/core/components/issues/navbar/theme.tsx b/apps/space/core/components/issues/navbar/theme.tsx index 15aa95f534..21df650769 100644 --- a/apps/space/core/components/issues/navbar/theme.tsx +++ b/apps/space/core/components/issues/navbar/theme.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; +import { Moon, Sun } from "lucide-react"; export const NavbarTheme = observer(function NavbarTheme() { // states @@ -23,7 +24,7 @@ export const NavbarTheme = observer(function NavbarTheme() { onClick={handleTheme} className="relative grid size-7 place-items-center rounded-sm bg-layer-transparent hover:bg-layer-transparent-hover text-primary" > - {appTheme === "light" ? "dark_mode" : "light_mode"} + {appTheme === "light" ? : } ); }); diff --git a/apps/space/core/components/issues/navbar/user-avatar.tsx b/apps/space/core/components/issues/navbar/user-avatar.tsx index c2b827695b..7cbdc6a88d 100644 --- a/apps/space/core/components/issues/navbar/user-avatar.tsx +++ b/apps/space/core/components/issues/navbar/user-avatar.tsx @@ -87,7 +87,7 @@ export const UserAvatar = observer(function UserAvatar() { >
) : (
-

- +

+ Sign in to add your comment

diff --git a/apps/space/core/components/issues/peek-overview/issue-properties.tsx b/apps/space/core/components/issues/peek-overview/issue-properties.tsx index c933fed2df..0b73eed7b2 100644 --- a/apps/space/core/components/issues/peek-overview/issue-properties.tsx +++ b/apps/space/core/components/issues/peek-overview/issue-properties.tsx @@ -1,12 +1,17 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +import { LinkIcon } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { StatePropertyIcon, StateGroupIcon, PriorityPropertyIcon, DueDatePropertyIcon } from "@plane/propel/icons"; +import { + StatePropertyIcon, + StateGroupIcon, + PriorityPropertyIcon, + DueDatePropertyIcon, + PriorityIcon, +} from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { cn, getIssuePriorityFilters } from "@plane/utils"; -// components -import { Icon } from "@/components/ui"; // helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; @@ -58,7 +63,7 @@ export const PeekOverviewIssueProperties = observer(function PeekOverviewIssuePr
@@ -94,11 +99,7 @@ export const PeekOverviewIssueProperties = observer(function PeekOverviewIssuePr : "border-priority-none text-priority-none" }`} > - {priority && ( - - - - )} + {priority && } {t(priority?.titleTranslationKey || "common.none")}
diff --git a/apps/space/core/components/issues/reactions/issue-vote-reactions.tsx b/apps/space/core/components/issues/reactions/issue-vote-reactions.tsx index c4eddb9278..73e14cd0d2 100644 --- a/apps/space/core/components/issues/reactions/issue-vote-reactions.tsx +++ b/apps/space/core/components/issues/reactions/issue-vote-reactions.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { ArrowDown, ArrowUp } from "lucide-react"; import { observer } from "mobx-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; // plane imports @@ -108,7 +109,7 @@ export const IssueVotes = observer(function IssueVotes(props: TIssueVotes) { } )} > - arrow_upward_alt + {allUpVotes.length} @@ -149,7 +150,7 @@ export const IssueVotes = observer(function IssueVotes(props: TIssueVotes) { } )} > - arrow_downward_alt + {allDownVotes.length} diff --git a/apps/space/core/components/ui/icon.tsx b/apps/space/core/components/ui/icon.tsx deleted file mode 100644 index 418be108c9..0000000000 --- a/apps/space/core/components/ui/icon.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; - -type Props = { - iconName: string; - className?: string; -}; - -export function Icon({ iconName, className = "" }: Props) { - return {iconName}; -} diff --git a/apps/space/core/components/ui/index.ts b/apps/space/core/components/ui/index.ts deleted file mode 100644 index b975409af4..0000000000 --- a/apps/space/core/components/ui/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./icon"; diff --git a/apps/space/core/components/views/auth.tsx b/apps/space/core/components/views/auth.tsx index 04b11626d6..3fdf137356 100644 --- a/apps/space/core/components/views/auth.tsx +++ b/apps/space/core/components/views/auth.tsx @@ -6,7 +6,7 @@ import { AuthHeader } from "./header"; export function AuthView() { return ( -
+
diff --git a/apps/space/core/hooks/oauth/core.tsx b/apps/space/core/hooks/oauth/core.tsx new file mode 100644 index 0000000000..54fce85e05 --- /dev/null +++ b/apps/space/core/hooks/oauth/core.tsx @@ -0,0 +1,82 @@ +// plane imports +import { useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import { API_BASE_URL } from "@plane/constants"; +import type { TOAuthConfigs, TOAuthOption } from "@plane/types"; +// assets +import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; +import githubLightLogo from "@/app/assets/logos/github-black.png?url"; +import githubDarkLogo from "@/app/assets/logos/github-dark.svg?url"; +import gitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; +import googleLogo from "@/app/assets/logos/google-logo.svg?url"; +// hooks +import { useInstance } from "@/hooks/store/use-instance"; + +export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { + //router + const searchParams = useSearchParams(); + // query params + const next_path = searchParams.get("next_path"); + // theme + const { resolvedTheme } = useTheme(); + // store hooks + const { config } = useInstance(); + // derived values + const isOAuthEnabled = + (config && + (config?.is_google_enabled || + config?.is_github_enabled || + config?.is_gitlab_enabled || + config?.is_gitea_enabled)) || + false; + const oAuthOptions: TOAuthOption[] = [ + { + id: "google", + text: `${oauthActionText} with Google`, + icon: Google Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/google/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_google_enabled, + }, + { + id: "github", + text: `${oauthActionText} with GitHub`, + icon: ( + GitHub Logo + ), + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_github_enabled, + }, + { + id: "gitlab", + text: `${oauthActionText} with GitLab`, + icon: GitLab Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_gitlab_enabled, + }, + { + id: "gitea", + text: `${oauthActionText} with Gitea`, + icon: Gitea Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/gitea/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_gitea_enabled, + }, + ]; + + return { + isOAuthEnabled, + oAuthOptions, + }; +}; diff --git a/apps/space/core/hooks/oauth/extended.tsx b/apps/space/core/hooks/oauth/extended.tsx new file mode 100644 index 0000000000..d6793f9de6 --- /dev/null +++ b/apps/space/core/hooks/oauth/extended.tsx @@ -0,0 +1,7 @@ +// plane imports +import type { TOAuthConfigs } from "@plane/types"; + +export const useExtendedOAuthConfig = (_oauthActionText: string): TOAuthConfigs => ({ + isOAuthEnabled: false, + oAuthOptions: [], +}); diff --git a/apps/space/core/hooks/oauth/index.ts b/apps/space/core/hooks/oauth/index.ts new file mode 100644 index 0000000000..2b15648737 --- /dev/null +++ b/apps/space/core/hooks/oauth/index.ts @@ -0,0 +1,14 @@ +// plane imports +import type { TOAuthConfigs } from "@plane/types"; +// local imports +import { useCoreOAuthConfig } from "./core"; +import { useExtendedOAuthConfig } from "./extended"; + +export const useOAuthConfig = (oauthActionText: string = "Continue"): TOAuthConfigs => { + const coreOAuthConfig = useCoreOAuthConfig(oauthActionText); + const extendedOAuthConfig = useExtendedOAuthConfig(oauthActionText); + return { + isOAuthEnabled: coreOAuthConfig.isOAuthEnabled || extendedOAuthConfig.isOAuthEnabled, + oAuthOptions: [...coreOAuthConfig.oAuthOptions, ...extendedOAuthConfig.oAuthOptions], + }; +}; diff --git a/apps/space/core/store/profile.store.ts b/apps/space/core/store/profile.store.ts index 009b46ca49..84455bc08d 100644 --- a/apps/space/core/store/profile.store.ts +++ b/apps/space/core/store/profile.store.ts @@ -32,13 +32,9 @@ export class ProfileStore implements IProfileStore { 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, diff --git a/apps/space/package.json b/apps/space/package.json index 774b08b4b7..f1fef4b763 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -18,6 +18,9 @@ }, "dependencies": { "@bprogress/core": "catalog:", + "@fontsource-variable/inter": "5.2.8", + "@fontsource/ibm-plex-mono": "5.2.7", + "@fontsource/material-symbols-rounded": "5.2.30", "@headlessui/react": "^1.7.19", "@plane/constants": "workspace:*", "@plane/editor": "workspace:*", diff --git a/apps/space/styles/globals.css b/apps/space/styles/globals.css index d179aec6b9..ba7f052d19 100644 --- a/apps/space/styles/globals.css +++ b/apps/space/styles/globals.css @@ -52,7 +52,7 @@ /* Progress Bar Styles */ :root { - --bprogress-color: rgb(var(--color-primary-100)) !important; + --bprogress-color: var(--background-color-accent-primary); --bprogress-height: 2.5px !important; } @@ -63,8 +63,8 @@ .bprogress .bar { background: linear-gradient( 90deg, - rgba(var(--color-primary-100), 0.8) 0%, - rgba(var(--color-primary-100), 1) 100% + --alpha(var(--background-color-accent-primary) / 80%) 0%, + --alpha(var(--background-color-accent-primary) / 100%) 100% ) !important; will-change: width, opacity; } @@ -72,7 +72,7 @@ .bprogress .peg { display: block; box-shadow: - 0 0 8px rgba(var(--color-primary-100), 0.6), - 0 0 4px rgba(var(--color-primary-100), 0.4) !important; + 0 0 8px --alpha(var(--background-color-accent-primary) / 60%), + 0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important; will-change: transform, opacity; } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx index c6b225de8f..f3a5fe3e80 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx @@ -67,22 +67,27 @@ function AnalyticsPage({ params }: Route.ComponentProps) { {workspaceProjectIds && ( <> {workspaceProjectIds.length > 0 || loader === "init-loader" ? ( -
+
- + {ANALYTICS_TABS.map((tab) => ( { + if (!tab.isDisabled) { + handleTabChange(tab.key); + } + }} > {tab.label} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx index 1aba8eae83..d9f7d82b0b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -4,6 +4,7 @@ import { useTheme } from "next-themes"; import useSWR from "swr"; // plane imports import { useTranslation } from "@plane/i18n"; +import type { TIssue } from "@plane/types"; import { EIssueServiceType } from "@plane/types"; import { Loader } from "@plane/ui"; // assets @@ -12,7 +13,6 @@ import emptyIssueLight from "@/app/assets/empty-state/search/issues-light.webp?u // components import { EmptyState } from "@/components/common/empty-state"; import { PageHead } from "@/components/core/page-title"; -import { IssueDetailRoot } from "@/components/issues/issue-detail"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; @@ -21,9 +21,11 @@ import { useAppRouter } from "@/hooks/use-app-router"; // plane web imports import { useWorkItemProperties } from "@/plane-web/hooks/use-issue-properties"; import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; +import { WorkItemDetailRoot } from "@/plane-web/components/browse/workItem-detail"; + import type { Route } from "./+types/page"; -function IssueDetailsPage({ params }: Route.ComponentProps) { +export const IssueDetailsPage = observer(function IssueDetailsPage({ params }: Route.ComponentProps) { // router const router = useAppRouter(); const { workspaceSlug, workItem } = params; @@ -35,18 +37,21 @@ function IssueDetailsPage({ params }: Route.ComponentProps) { fetchIssueWithIdentifier, issue: { getIssueById }, } = useIssueDetail(); - const { getProjectById } = useProject(); + const { getProjectById, getProjectByIdentifier } = useProject(); const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme(); const [projectIdentifier, sequence_id] = workItem.split("-"); // fetching issue details - const { data, isLoading, error } = useSWR(`ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}`, () => - fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) + const { data, isLoading, error } = useSWR( + `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}`, + () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) ); - const issueId = data?.id; - const projectId = data?.project_id; + // derived values + const projectDetails = getProjectByIdentifier(projectIdentifier); + const issueId = data?.id; + const projectId = data?.project_id ?? projectDetails?.id ?? ""; const issue = getIssueById(issueId?.toString() || "") || undefined; const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined; const issueLoader = !issue || isLoading; @@ -77,51 +82,56 @@ function IssueDetailsPage({ params }: Route.ComponentProps) { if (data?.is_intake) { router.push(`/${workspaceSlug}/projects/${data.project_id}/intake/?currentTab=open&inboxIssueId=${data?.id}`); } - }, [workspaceSlug, data]); + }, [workspaceSlug, data, router]); + + if (error && !isLoading) { + return ( + router.push(`/${workspaceSlug}/workspace-views/all-issues/`), + }} + /> + ); + } + + if (issueLoader) { + return ( + +
+ + + + +
+
+ + + + +
+
+ ); + } return ( <> - {error && !issueLoader ? ( - router.push(`/${workspaceSlug}/workspace-views/all-issues/`), - }} - /> - ) : issueLoader ? ( - -
- - - - -
-
- - - - -
-
- ) : ( - projectId && - issueId && ( - - - - ) + {workspaceSlug && projectId && issueId && ( + + + )} ); -} +}); -export default observer(IssueDetailsPage); +export default IssueDetailsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index 7e97e57b6d..b74886bae7 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -14,6 +14,7 @@ import { import { usePlatformOS } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; +import { IconButton } from "@plane/propel/icon-button"; import { CycleIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; @@ -236,7 +237,6 @@ export const CycleIssuesHeader = observer(function CycleIssuesHeader() { + )} - + {moduleId && ( { try { await inviteMembersToWorkspace(workspaceSlug, data); + void mutateWorkspaceMembersActivity(workspaceSlug); setInviteModal(false); @@ -127,7 +128,7 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP "opacity-60": !canPerformWorkspaceMemberActions, })} > -
+

{t("workspace_settings.settings.members.title")} {workspaceMemberIds && workspaceMemberIds.length > 0 && ( diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx index 1df1df643c..f3098f675a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx @@ -10,7 +10,7 @@ import { SettingsHeading } from "@/components/settings/heading"; // hooks import { useUserProfile } from "@/hooks/store/user"; -function ProfileAppearancePage() { +const ProfileAppearancePage = observer(() => { const { t } = useTranslation(); // hooks const { data: userProfile } = useUserProfile(); @@ -34,6 +34,6 @@ function ProfileAppearancePage() {

); -} +}); -export default observer(ProfileAppearancePage); +export default ProfileAppearancePage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx index cf8ff8c07f..e68070da13 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx @@ -149,12 +149,12 @@ function SecurityPage() { /> {showPassword?.oldPassword ? ( handleShowPassword("oldPassword")} /> ) : ( handleShowPassword("oldPassword")} /> )} @@ -187,12 +187,12 @@ function SecurityPage() { /> {showPassword?.password ? ( handleShowPassword("password")} /> ) : ( handleShowPassword("password")} /> )} @@ -227,12 +227,12 @@ function SecurityPage() { /> {showPassword?.confirmPassword ? ( handleShowPassword("confirmPassword")} /> ) : ( handleShowPassword("confirmPassword")} /> )} diff --git a/apps/web/app/(all)/create-workspace/page.tsx b/apps/web/app/(all)/create-workspace/page.tsx index 43593e8152..0228efa2dc 100644 --- a/apps/web/app/(all)/create-workspace/page.tsx +++ b/apps/web/app/(all)/create-workspace/page.tsx @@ -52,11 +52,11 @@ const CreateWorkspacePage = observer(function CreateWorkspacePage() { return ( -
+
diff --git a/apps/web/app/(all)/invitations/page.tsx b/apps/web/app/(all)/invitations/page.tsx index 656051ee38..f16a8a24e6 100644 --- a/apps/web/app/(all)/invitations/page.tsx +++ b/apps/web/app/(all)/invitations/page.tsx @@ -131,7 +131,7 @@ function UserInvitationsPage() {
diff --git a/apps/web/app/(all)/profile/appearance/page.tsx b/apps/web/app/(all)/profile/appearance/page.tsx index 68ba9779e6..d0d05588d8 100644 --- a/apps/web/app/(all)/profile/appearance/page.tsx +++ b/apps/web/app/(all)/profile/appearance/page.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; // plane imports @@ -6,9 +6,8 @@ import type { I_THEME_OPTION } from "@plane/constants"; import { THEME_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { setPromiseToast } from "@plane/propel/toast"; -import type { IUserTheme } from "@plane/types"; +import { applyCustomTheme } from "@plane/utils"; // components -import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; import { LogoSpinner } from "@/components/common/logo-spinner"; import { PageHead } from "@/components/core/page-title"; import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; @@ -19,46 +18,59 @@ import { ProfileSettingContentWrapper } from "@/components/profile/profile-setti import { useUserProfile } from "@/hooks/store/user"; function ProfileAppearancePage() { - const { t } = useTranslation(); - const { setTheme } = useTheme(); - // states - const [currentTheme, setCurrentTheme] = useState(null); - // hooks + // store hooks const { data: userProfile, updateUserTheme } = useUserProfile(); - - useEffect(() => { - if (userProfile?.theme?.theme) { - const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme); - if (userThemeOption) { - setCurrentTheme(userThemeOption); - } - } + // theme + const { setTheme } = useTheme(); + // translation + const { t } = useTranslation(); + // derived values + const currentTheme = useMemo(() => { + const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme); + return userThemeOption || null; }, [userProfile?.theme?.theme]); - const handleThemeChange = (themeOption: I_THEME_OPTION) => { - applyThemeChange({ theme: themeOption.value }); + const handleThemeChange = useCallback( + async (themeOption: I_THEME_OPTION) => { + setTheme(themeOption.value); - const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value }); - setPromiseToast(updateCurrentUserThemePromise, { - loading: "Updating theme...", - success: { - title: "Success!", - message: () => "Theme updated successfully!", - }, - error: { - title: "Error!", - message: () => "Failed to Update the theme", - }, - }); - }; + // If switching to custom theme and user has saved custom colors, apply them immediately + if ( + themeOption.value === "custom" && + userProfile?.theme?.primary && + userProfile?.theme?.background && + userProfile?.theme?.darkPalette !== undefined + ) { + applyCustomTheme( + userProfile.theme.primary, + userProfile.theme.background, + userProfile.theme.darkPalette ? "dark" : "light" + ); + } - const applyThemeChange = (theme: Partial) => { - setTheme(theme?.theme || "system"); - - if (theme?.theme === "custom" && theme?.palette) { - applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false); - } else unsetCustomCssVariables(); - }; + const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value }); + setPromiseToast(updateCurrentUserThemePromise, { + loading: "Updating theme...", + success: { + title: "Theme updated", + message: () => "Reloading to apply changes...", + }, + error: { + title: "Error!", + message: () => "Failed to update theme. Please try again.", + }, + }); + // Wait for the promise to resolve, then reload after showing toast + try { + await updateCurrentUserThemePromise; + window.location.reload(); + } catch (error) { + // Error toast already shown by setPromiseToast + console.error("Error updating theme:", error); + } + }, + [setTheme, updateUserTheme, userProfile] + ); return ( <> @@ -75,7 +87,7 @@ function ProfileAppearancePage() {
- {userProfile?.theme?.theme === "custom" && } + {userProfile?.theme?.theme === "custom" && } ) : (
diff --git a/apps/web/app/(all)/profile/security/page.tsx b/apps/web/app/(all)/profile/security/page.tsx index f121880071..99f19bce88 100644 --- a/apps/web/app/(all)/profile/security/page.tsx +++ b/apps/web/app/(all)/profile/security/page.tsx @@ -147,12 +147,12 @@ function SecurityPage() { /> {showPassword?.oldPassword ? ( handleShowPassword("oldPassword")} /> ) : ( handleShowPassword("oldPassword")} /> )} @@ -185,12 +185,12 @@ function SecurityPage() { /> {showPassword?.password ? ( handleShowPassword("password")} /> ) : ( handleShowPassword("password")} /> )} @@ -225,12 +225,12 @@ function SecurityPage() { /> {showPassword?.confirmPassword ? ( handleShowPassword("confirmPassword")} /> ) : ( handleShowPassword("confirmPassword")} /> )} diff --git a/apps/web/app/error/dev.tsx b/apps/web/app/error/dev.tsx index 68a2db27d7..2e87859f43 100644 --- a/apps/web/app/error/dev.tsx +++ b/apps/web/app/error/dev.tsx @@ -55,7 +55,7 @@ export function DevErrorComponent({ error, onGoHome, onReload }: DevErrorCompone

Error Data

-

{error.data}

+

{error.data}

@@ -95,7 +95,7 @@ export function DevErrorComponent({ error, onGoHome, onReload }: DevErrorCompone

Stack Trace

-
+                    
                       {error.stack}
                     
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 3638de072c..04a46a2058 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,8 +2,6 @@ import Script from "next/script"; // styles import "@/styles/globals.css"; -import "@/styles/power-k.css"; -import "@/styles/emoji.css"; import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; @@ -78,7 +76,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
-
+
{children}
diff --git a/apps/web/app/provider.tsx b/apps/web/app/provider.tsx index 7d6811052c..4d23fed9d3 100644 --- a/apps/web/app/provider.tsx +++ b/apps/web/app/provider.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense } from "react"; -import { useTheme, ThemeProvider } from "next-themes"; +import { useTheme } from "next-themes"; import { SWRConfig } from "swr"; // Plane Imports import { WEB_SWR_CONFIG } from "@plane/constants"; @@ -9,44 +9,45 @@ import { Toast } from "@plane/propel/toast"; import { resolveGeneralTheme } from "@plane/utils"; // polyfills import "@/lib/polyfills"; -// progress bar -import { AppProgressBar } from "@/lib/b-progress"; // mobx store provider import { StoreProvider } from "@/lib/store-context"; -// wrappers -import { InstanceWrapper } from "@/lib/wrappers/instance-wrapper"; // lazy imports +const AppProgressBar = lazy(function AppProgressBar() { + return import("@/lib/b-progress/AppProgressBar"); +}); + const StoreWrapper = lazy(function StoreWrapper() { return import("@/lib/wrappers/store-wrapper"); }); -const PostHogProvider = lazy(function PostHogProvider() { - return import("@/lib/posthog-provider"); +const InstanceWrapper = lazy(function InstanceWrapper() { + return import("@/lib/wrappers/instance-wrapper"); }); const ChatSupportModal = lazy(function ChatSupportModal() { return import("@/components/global/chat-support-modal"); }); +const PostHogProvider = lazy(function PostHogProvider() { + return import("@/lib/posthog-provider"); +}); + export interface IAppProvider { children: React.ReactNode; } -function ToastWithTheme() { - const { resolvedTheme } = useTheme(); - return ; -} - export function AppProvider(props: IAppProvider) { const { children } = props; // themes + const { resolvedTheme } = useTheme(); + return ( - + <> - + @@ -58,7 +59,7 @@ export function AppProvider(props: IAppProvider) { - + ); } diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx index a0d8c20764..2fbfd46c64 100644 --- a/apps/web/app/root.tsx +++ b/apps/web/app/root.tsx @@ -3,6 +3,7 @@ import * as Sentry from "@sentry/react-router"; import Script from "next/script"; import { Links, Meta, Outlet, Scripts } from "react-router"; import type { LinksFunction } from "react-router"; +import { ThemeProvider, useTheme } from "next-themes"; // plane imports import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; import { cn } from "@plane/utils"; @@ -14,12 +15,18 @@ import faviconIco from "@/app/assets/favicon/favicon.ico?url"; import icon180 from "@/app/assets/icons/icon-180x180.png?url"; import icon512 from "@/app/assets/icons/icon-512x512.png?url"; import ogImage from "@/app/assets/og-image.png?url"; -import { LogoSpinner } from "@/components/common/logo-spinner"; import globalStyles from "@/styles/globals.css?url"; import type { Route } from "./+types/root"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; // local import { CustomErrorComponent } from "./error"; import { AppProvider } from "./provider"; +// fonts +import "@fontsource-variable/inter"; +import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url"; +import "@fontsource/material-symbols-rounded"; +import "@fontsource/ibm-plex-mono"; const APP_TITLE = "Plane | Simple, extensible, open-source project management tool."; @@ -33,13 +40,20 @@ export const links: LinksFunction = () => [ { rel: "apple-touch-icon", sizes: "512x512", href: icon512 }, { rel: "manifest", href: "/manifest.json" }, { rel: "stylesheet", href: globalStyles }, + { + rel: "preload", + href: interVariableWoff2, + as: "font", + type: "font/woff2", + crossOrigin: "anonymous", + }, ]; export function Layout({ children }: { children: ReactNode }) { const isSessionRecorderEnabled = parseInt(process.env.VITE_ENABLE_SESSION_RECORDER || "0"); return ( - + @@ -54,14 +68,12 @@ export function Layout({ children }: { children: ReactNode }) { - +
- -
-
{children}
-
-
+ + {children} + {!!isSessionRecorderEnabled && process.env.VITE_SESSION_RECORDER_KEY && (