diff --git a/apps/api/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py
index 23eeebec1c1..fed0c5e17e6 100644
--- a/apps/api/plane/license/api/views/instance.py
+++ b/apps/api/plane/license/api/views/instance.py
@@ -175,6 +175,7 @@ def get(self, request):
data["app_base_url"] = settings.APP_BASE_URL
data["instance_changelog_url"] = settings.INSTANCE_CHANGELOG_URL
+ data["is_self_managed"] = settings.IS_SELF_MANAGED
instance_data = serializer.data
instance_data["workspaces_exist"] = Workspace.objects.count() >= 1
diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py
index 41780521668..a9e9925c28c 100644
--- a/apps/api/plane/settings/common.py
+++ b/apps/api/plane/settings/common.py
@@ -25,6 +25,9 @@
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", "0"))
+# Self-hosted mode
+IS_SELF_MANAGED = True
+
# Allowed Hosts
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
@@ -69,9 +72,7 @@
# Rest Framework settings
REST_FRAMEWORK = {
- "DEFAULT_AUTHENTICATION_CLASSES": (
- "rest_framework.authentication.SessionAuthentication",
- ),
+ "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",),
"DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",),
"DEFAULT_THROTTLE_RATES": {
"anon": "30/minute",
diff --git a/apps/web/ce/components/global/index.ts b/apps/web/ce/components/global/index.ts
index c87c8ae0273..08b85c764c0 100644
--- a/apps/web/ce/components/global/index.ts
+++ b/apps/web/ce/components/global/index.ts
@@ -1,2 +1 @@
export * from "./version-number";
-export * from "./product-updates-header";
diff --git a/apps/web/ce/components/global/product-updates/changelog.tsx b/apps/web/ce/components/global/product-updates/changelog.tsx
new file mode 100644
index 00000000000..672b7490bc3
--- /dev/null
+++ b/apps/web/ce/components/global/product-updates/changelog.tsx
@@ -0,0 +1,83 @@
+import { useState, useEffect, useRef } from "react";
+import { observer } from "mobx-react";
+// hooks
+import { Loader } from "@plane/ui";
+import { ProductUpdatesFallback } from "@/components/global/product-updates/fallback";
+import { useInstance } from "@/hooks/store/use-instance";
+
+export const ProductUpdatesChangelog = observer(function ProductUpdatesChangelog() {
+ // refs
+ const isLoadingRef = useRef(true);
+ // states
+ const [isLoading, setIsLoading] = useState(true);
+ const [hasError, setHasError] = useState(false);
+ // store hooks
+ const { config } = useInstance();
+ // derived values
+ const changeLogUrl = config?.instance_changelog_url;
+ const shouldShowFallback = !changeLogUrl || changeLogUrl === "" || hasError;
+
+ // timeout fallback - if iframe doesn't load within 15 seconds, show error
+ useEffect(() => {
+ if (!changeLogUrl || changeLogUrl === "") {
+ setIsLoading(false);
+ isLoadingRef.current = false;
+ return;
+ }
+
+ setIsLoading(true);
+ setHasError(false);
+ isLoadingRef.current = true;
+
+ const timeoutId = setTimeout(() => {
+ if (isLoadingRef.current) {
+ setHasError(true);
+ setIsLoading(false);
+ isLoadingRef.current = false;
+ }
+ }, 15000); // 15 second timeout
+
+ return () => {
+ clearTimeout(timeoutId);
+ };
+ }, [changeLogUrl]);
+
+ const handleIframeLoad = () => {
+ setTimeout(() => {
+ isLoadingRef.current = false;
+ setIsLoading(false);
+ }, 1000);
+ };
+
+ const handleIframeError = () => {
+ isLoadingRef.current = false;
+ setHasError(true);
+ setIsLoading(false);
+ };
+
+ // Show fallback if URL is missing, empty, or iframe failed to load
+ if (shouldShowFallback) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {isLoading && (
+
+
+
+ )}
+
+
+ );
+});
diff --git a/apps/web/ce/components/global/product-updates-header.tsx b/apps/web/ce/components/global/product-updates/header.tsx
similarity index 79%
rename from apps/web/ce/components/global/product-updates-header.tsx
rename to apps/web/ce/components/global/product-updates/header.tsx
index 83e2c489c84..196d621f353 100644
--- a/apps/web/ce/components/global/product-updates-header.tsx
+++ b/apps/web/ce/components/global/product-updates/header.tsx
@@ -1,10 +1,8 @@
import { observer } from "mobx-react";
+import packageJson from "package.json";
import { useTranslation } from "@plane/i18n";
-import { PlaneLogo } from "@plane/propel/icons";
// helpers
import { cn } from "@plane/utils";
-// package.json
-import packageJson from "package.json";
export const ProductUpdatesHeader = observer(function ProductUpdatesHeader() {
const { t } = useTranslation();
@@ -20,9 +18,6 @@ export const ProductUpdatesHeader = observer(function ProductUpdatesHeader() {
{t("version")}: v{packageJson.version}
-
);
});
diff --git a/apps/web/core/components/core/image-picker-popover.tsx b/apps/web/core/components/core/image-picker-popover.tsx
index 7eefa0d3ab0..d3313040587 100644
--- a/apps/web/core/components/core/image-picker-popover.tsx
+++ b/apps/web/core/components/core/image-picker-popover.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useRef, useCallback } from "react";
+import React, { useState, useRef, useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useDropzone } from "react-dropzone";
@@ -16,24 +16,16 @@ import { Input, Loader } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// hooks
+import { useInstance } from "@/hooks/store/use-instance";
import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down";
// services
import { FileService } from "@/services/file.service";
-const tabOptions = [
- {
- key: "unsplash",
- title: "Unsplash",
- },
- {
- key: "images",
- title: "Images",
- },
- {
- key: "upload",
- title: "Upload",
- },
-];
+type TTabOption = {
+ key: string;
+ title: string;
+ isEnabled: boolean;
+};
type Props = {
label: string | React.ReactNode;
@@ -63,6 +55,30 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
const ref = useRef(null);
// router params
const { workspaceSlug } = useParams();
+ // store hooks
+ const { config } = useInstance();
+ // derived values
+ const hasUnsplashConfigured = config?.has_unsplash_configured || false;
+ const tabOptions: TTabOption[] = useMemo(
+ () => [
+ {
+ key: "unsplash",
+ title: "Unsplash",
+ isEnabled: hasUnsplashConfigured,
+ },
+ {
+ key: "images",
+ title: "Images",
+ isEnabled: true,
+ },
+ {
+ key: "upload",
+ title: "Upload",
+ isEnabled: true,
+ },
+ ],
+ [hasUnsplashConfigured]
+ );
const { data: unsplashImages, error: unsplashError } = useSWR(
`UNSPLASH_IMAGES_${searchParams}`,
diff --git a/apps/web/core/components/global/product-updates/fallback.tsx b/apps/web/core/components/global/product-updates/fallback.tsx
new file mode 100644
index 00000000000..ac865f437ee
--- /dev/null
+++ b/apps/web/core/components/global/product-updates/fallback.tsx
@@ -0,0 +1,32 @@
+import { EmptyStateDetailed } from "@plane/propel/empty-state";
+
+type TProductUpdatesFallbackProps = {
+ description: string;
+ variant: "cloud" | "self-managed";
+};
+
+export function ProductUpdatesFallback(props: TProductUpdatesFallbackProps) {
+ const { description, variant } = props;
+ // derived values
+ const changelogUrl =
+ variant === "cloud"
+ ? "https://plane.so/changelog?category=cloud"
+ : "https://plane.so/changelog?category=self-hosted";
+
+ return (
+
+ window.open(changelogUrl, "_blank"),
+ },
+ ]}
+ />
+
+ );
+}
diff --git a/apps/web/core/components/global/product-updates/modal.tsx b/apps/web/core/components/global/product-updates/modal.tsx
index 7b9fde3e312..87ac429c2e4 100644
--- a/apps/web/core/components/global/product-updates/modal.tsx
+++ b/apps/web/core/components/global/product-updates/modal.tsx
@@ -1,18 +1,15 @@
-import type { FC } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { USER_TRACKER_ELEMENTS } from "@plane/constants";
-import { useTranslation } from "@plane/i18n";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
import { ProductUpdatesFooter } from "@/components/global";
// helpers
import { captureView } from "@/helpers/event-tracker.helper";
-// hooks
-import { useInstance } from "@/hooks/store/use-instance";
// plane web components
-import { ProductUpdatesHeader } from "@/plane-web/components/global";
+import { ProductUpdatesChangelog } from "@/plane-web/components/global/product-updates/changelog";
+import { ProductUpdatesHeader } from "@/plane-web/components/global/product-updates/header";
export type ProductUpdatesModalProps = {
isOpen: boolean;
@@ -21,8 +18,6 @@ export type ProductUpdatesModalProps = {
export const ProductUpdatesModal = observer(function ProductUpdatesModal(props: ProductUpdatesModalProps) {
const { isOpen, handleClose } = props;
- const { t } = useTranslation();
- const { config } = useInstance();
useEffect(() => {
if (isOpen) {
@@ -33,27 +28,7 @@ export const ProductUpdatesModal = observer(function ProductUpdatesModal(props:
return (
-
- {config?.instance_changelog_url && config?.instance_changelog_url !== "" ? (
-
- ) : (
-
-
{t("we_are_having_trouble_fetching_the_updates")}
-
-
- )}
-
+
);
diff --git a/apps/web/core/components/onboarding/steps/profile/root.tsx b/apps/web/core/components/onboarding/steps/profile/root.tsx
index c74daa8361a..3e352c1c6d1 100644
--- a/apps/web/core/components/onboarding/steps/profile/root.tsx
+++ b/apps/web/core/components/onboarding/steps/profile/root.tsx
@@ -22,6 +22,7 @@ import { AuthService } from "@/services/auth.service";
import { CommonOnboardingHeader } from "../common";
import { MarketingConsent } from "./consent";
import { SetPasswordRoot } from "./set-password";
+import { useInstance } from "@/hooks/store/use-instance";
type Props = {
handleStepChange: (step: EOnboardingSteps, skipInvites?: boolean) => void;
@@ -55,6 +56,7 @@ export const ProfileSetupStep = observer(function ProfileSetupStep({ handleStepC
// store hooks
const { data: user, updateCurrentUser } = useUser();
const { updateUserProfile } = useUserProfile();
+ const { config: instanceConfig } = useInstance();
// form info
const {
getValues,
@@ -253,12 +255,14 @@ export const ProfileSetupStep = observer(function ProfileSetupStep({ handleStepC
{/* Marketing Consent */}
-
- setValue("has_marketing_email_consent", has_marketing_email_consent)
- }
- />
+ {!instanceConfig.is_self_managed && (
+
+ setValue("has_marketing_email_consent", has_marketing_email_consent)
+ }
+ />
+ )}
);
});
diff --git a/packages/propel/src/empty-state/assets/asset-registry.tsx b/packages/propel/src/empty-state/assets/asset-registry.tsx
index 5ad23bb09c7..e1eb823c6f1 100644
--- a/packages/propel/src/empty-state/assets/asset-registry.tsx
+++ b/packages/propel/src/empty-state/assets/asset-registry.tsx
@@ -33,6 +33,7 @@ import {
ArchivedCycleVerticalStackIllustration,
ArchivedModuleVerticalStackIllustration,
ArchivedWorkItemVerticalStackIllustration,
+ ChangelogVerticalStackIllustration,
CustomerVerticalStackIllustration,
CycleVerticalStackIllustration,
DashboardVerticalStackIllustration,
@@ -80,6 +81,7 @@ export const VERTICAL_STACK_ASSETS: Record
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/propel/src/empty-state/assets/vertical-stack/constant.tsx b/packages/propel/src/empty-state/assets/vertical-stack/constant.tsx
index 85888d5e54f..2f16f116d1a 100644
--- a/packages/propel/src/empty-state/assets/vertical-stack/constant.tsx
+++ b/packages/propel/src/empty-state/assets/vertical-stack/constant.tsx
@@ -2,6 +2,7 @@ import {
ArchivedCycleVerticalStackIllustration,
ArchivedModuleVerticalStackIllustration,
ArchivedWorkItemVerticalStackIllustration,
+ ChangelogVerticalStackIllustration,
CustomerVerticalStackIllustration,
CycleVerticalStackIllustration,
DashboardVerticalStackIllustration,
@@ -33,6 +34,10 @@ export const VerticalStackAssetsMap = [
asset: ,
title: "ArchivedWorkItemVerticalStackIllustration",
},
+ {
+ asset: ,
+ title: "ChangelogVerticalStackIllustration",
+ },
{
asset: ,
title: "CustomerVerticalStackIllustration",
diff --git a/packages/propel/src/empty-state/assets/vertical-stack/index.ts b/packages/propel/src/empty-state/assets/vertical-stack/index.ts
index 70fc2f29661..2a9372167f2 100644
--- a/packages/propel/src/empty-state/assets/vertical-stack/index.ts
+++ b/packages/propel/src/empty-state/assets/vertical-stack/index.ts
@@ -2,6 +2,7 @@ export * from "./404-error";
export * from "./archived-cycle";
export * from "./archived-module";
export * from "./archived-work-item";
+export * from "./changelog";
export * from "./customer";
export * from "./cycle";
export * from "./dashboard";
diff --git a/packages/propel/src/empty-state/detailed-empty-state.tsx b/packages/propel/src/empty-state/detailed-empty-state.tsx
index c105e0118f4..82f7103b68a 100644
--- a/packages/propel/src/empty-state/detailed-empty-state.tsx
+++ b/packages/propel/src/empty-state/detailed-empty-state.tsx
@@ -15,16 +15,29 @@ export function EmptyStateDetailed({
rootClassName,
assetClassName,
customButton,
+ align = "start",
}: BaseEmptyStateCommonProps) {
// Determine which asset to use: assetKey takes precedence, fallback to custom asset
const resolvedAsset = assetKey ? getDetailedAsset(assetKey as DetailedAssetType, assetClassName) : asset;
return (
-
+
{resolvedAsset &&
{resolvedAsset}
}
-
+
{(title || description) && (
{title &&
{title}
}
diff --git a/packages/types/src/instance/base.ts b/packages/types/src/instance/base.ts
index 742698f5631..975e1238609 100644
--- a/packages/types/src/instance/base.ts
+++ b/packages/types/src/instance/base.ts
@@ -56,6 +56,7 @@ export interface IInstanceConfig {
app_base_url: string | undefined;
space_base_url: string | undefined;
admin_base_url: string | undefined;
+ is_self_managed: boolean;
// intercom
is_intercom_enabled: boolean;
intercom_app_id: string | undefined;