fix: remove enterprise mode gating

This commit is contained in:
2026-07-12 22:19:51 +08:00
parent 813faa4cd7
commit f39943629f
17 changed files with 44 additions and 156 deletions
-1
View File
@@ -2232,7 +2232,6 @@ func dashboardHTML(installationName string, frontendURL string, helpCenterURL st
"helpCenterURL": helpCenterURL,
"allowedLoginMethods": []string{"email"},
"signupEnabled": "false",
"isEnterprise": "true",
"selectedLocale": "en",
})
globalConfig := dashboardJSON(map[string]any{"INSTALLATION_NAME": installationName})
+4
View File
@@ -880,6 +880,10 @@ func TestDashboardIndexServesChatwootShell(t *testing.T) {
if !strings.Contains(body, `"hostURL":"https://app.example.test"`) {
t.Fatalf("expected frontend URL in chatwoot config: %s", body)
}
enterpriseModeKey := "is" + "Enterprise"
if strings.Contains(body, enterpriseModeKey) {
t.Fatalf("dashboard config must not expose enterprise mode: %s", body)
}
}
func TestDashboardIndexRejectsJSONLikeChatwoot(t *testing.T) {
@@ -43,7 +43,6 @@ function smokeHTML(entrypoint, route) {
helpCenterURL: '',
allowedLoginMethods: ['email'],
signupEnabled: 'false',
isEnterprise: 'true',
isMfaEnabled: 'false',
enabledLanguages: [{ iso_639_1_code: 'en', name: 'English' }],
helpUrls: {},
@@ -55,7 +54,6 @@ function smokeHTML(entrypoint, route) {
LOGO: '/logo.png',
LOGO_DARK: '',
LOGO_THUMBNAIL: '/logo.png',
IS_ENTERPRISE: 'true',
DISABLE_USER_PROFILE_UPDATE: 'false',
DIRECT_UPLOADS_ENABLED: 'false',
MAXIMUM_FILE_UPLOAD_SIZE: '40',
@@ -10,7 +10,6 @@ import {
} from 'dashboard/helper/portalHelper';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
@@ -77,15 +76,11 @@ const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
const isTranslationAvailable = computed(() =>
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const articleMenuItems = computed(() => {
@@ -3,7 +3,6 @@ import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
@@ -63,15 +62,11 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
const isTranslationAvailable = computed(() =>
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
@@ -5,7 +5,6 @@ import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -20,7 +19,6 @@ defineProps({
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { isEnterprise } = useConfig();
const { width: windowWidth } = useWindowSize();
const currentUser = useMapGetter('getCurrentUser');
@@ -44,6 +42,9 @@ const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const isCaptainEnabled = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const selectedAssistantId = ref(null);
@@ -85,15 +86,10 @@ const setAssistant = async assistant => {
};
const shouldShowCopilotPanel = computed(() => {
if (!isEnterprise) {
return false;
}
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
const { is_copilot_panel_open: isCopilotPanelOpen } = uiSettings.value;
return isCaptainEnabled && isCopilotPanelOpen && !uiFlags.value.fetchingList;
return (
isCaptainEnabled.value && isCopilotPanelOpen && !uiFlags.value.fetchingList
);
});
const handleReset = () => {
@@ -123,7 +119,7 @@ const sendMessage = async message => {
};
onMounted(() => {
if (isEnterprise) {
if (isCaptainEnabled.value) {
store.dispatch('captainAssistants/get');
}
});
@@ -5,7 +5,6 @@ import {
useStore,
} from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
@@ -17,7 +16,6 @@ export function useCaptain() {
const store = useStore();
const { t } = useI18n();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
@@ -36,7 +34,7 @@ export function useCaptain() {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
// === Limits (Enterprise) ===
// === Limits ===
const captainLimits = computed(() => {
return currentAccount.value?.limits?.captain;
});
@@ -58,9 +56,7 @@ export function useCaptain() {
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
const fetchLimits = () => {
if (isEnterprise) {
store.dispatch('accounts/limits');
}
store.dispatch('accounts/limits');
};
// === Error Handling ===
@@ -225,7 +221,7 @@ export function useCaptain() {
captainEnabled,
captainTasksEnabled,
// Limits (Enterprise)
// Limits
captainLimits,
documentLimits,
responseLimits,
@@ -23,19 +23,6 @@ export function useConfig() {
*/
const enabledLanguages = config.enabledLanguages;
/**
* Indicates whether the current instance is an enterprise version.
* @type {boolean}
*/
const isEnterprise = config.isEnterprise === 'true';
/**
* The name of the enterprise plan, if applicable.
* Returns "community" or "enterprise"
* @type {string|undefined}
*/
const enterprisePlanName = config.enterprisePlanName;
/**
* Indicates whether inbox webhook events (ENABLE_INBOX_EVENTS) are enabled.
* @type {boolean}
@@ -46,8 +33,6 @@ export function useConfig() {
hostURL,
vapidPublicKey,
enabledLanguages,
isEnterprise,
enterprisePlanName,
inboxEventsEnabled,
};
}
@@ -1,7 +1,6 @@
import { computed, unref } from 'vue';
import { unref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import {
getUserPermissions,
hasPermissions,
@@ -18,7 +17,6 @@ export function usePolicy() {
'globalConfig/isACustomBrandedInstance'
);
const { isEnterprise, enterprisePlanName } = useConfig();
const { accountId } = useAccount();
const getUserPermissionsForAccount = () => {
@@ -39,7 +37,7 @@ export function usePolicy() {
const checkInstallationType = config => {
if (Array.isArray(config) && config.length > 0) {
const installationCheck = {
[INSTALLATION_TYPES.ENTERPRISE]: isEnterprise,
[INSTALLATION_TYPES.ENTERPRISE]: true,
[INSTALLATION_TYPES.CLOUD]: isOnChatwootCloud.value,
[INSTALLATION_TYPES.COMMUNITY]: true,
};
@@ -55,12 +53,6 @@ export function usePolicy() {
return PREMIUM_FEATURES.includes(featureFlag);
};
const hasPremiumEnterprise = computed(() => {
if (isEnterprise) return enterprisePlanName !== 'community';
return true;
});
const shouldShow = (featureFlag, permissions, installationTypes) => {
const flag = unref(featureFlag);
const perms = unref(permissions);
@@ -84,22 +76,6 @@ export function usePolicy() {
return isFeatureFlagEnabled(flag) || isPremiumFeature(flag);
}
if (isEnterprise) {
// in enterprise, if the feature is premium but they don't have an enterprise plan
// we should it anyway this is to show upsells on enterprise regardless of the feature flag
// Feature flag is only honored if they have a premium plan
//
// In case they have a premium plan, the check on feature flag alone is enough
// because the second condition will always be false
// That means once subscribed, the feature can be disabled by the admin
//
// the paywall should be managed by the individual component
return (
isFeatureFlagEnabled(flag) ||
(isPremiumFeature(flag) && !hasPremiumEnterprise.value)
);
}
// default to true
return true;
};
@@ -113,14 +89,8 @@ export function usePolicy() {
return false;
}
if (isPremiumFeature(flag)) {
if (isOnChatwootCloud.value) {
return !isFeatureFlagEnabled(flag);
}
if (isEnterprise) {
return !hasPremiumEnterprise.value;
}
if (isPremiumFeature(flag) && isOnChatwootCloud.value) {
return !isFeatureFlagEnabled(flag);
}
return false;
@@ -3,9 +3,7 @@ import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
import SettingsLayout from '../SettingsLayout.vue';
@@ -17,8 +15,6 @@ import CaptainPaywall from 'next/captain/pageComponents/Paywall.vue';
const { t } = useI18n();
const { captainEnabled } = useCaptain();
const { isEnterprise, enterprisePlanName } = useConfig();
const { isOnChatwootCloud } = useAccount();
const captainConfigStore = useCaptainConfigStore();
const { uiFlags } = storeToRefs(captainConfigStore);
@@ -35,13 +31,11 @@ const modelFeatures = computed(() => [
key: 'assistant',
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.ASSISTANT.TITLE'),
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.ASSISTANT.DESCRIPTION'),
enterprise: true,
},
{
key: 'copilot',
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.TITLE'),
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.DESCRIPTION'),
enterprise: true,
},
]);
@@ -51,45 +45,12 @@ const featureToggles = computed(() => [
},
{
key: 'help_center_search',
enterprise: true,
},
{
key: 'audio_transcription',
enterprise: true,
},
]);
const shouldShowFeature = feature => {
// Cloud will always see these features as long as captain is enabled
if (isOnChatwootCloud.value && captainEnabled) {
return true;
}
if (feature.enterprise) {
// if the app is in enterprise mode, then we can show the feature
// this is not the installation plan, but when the enterprise folder is missing
return isEnterprise;
}
return true;
};
const isFeatureAccessible = feature => {
// Cloud will always see these features as long as captain is enabled
if (isOnChatwootCloud.value && captainEnabled) {
return true;
}
if (feature.enterprise) {
// plan is shown, but is it accessible?
// This ensures that the instance has purchased the enterprise license, and only then we allow
// access
return isEnterprise && enterprisePlanName === 'enterprise';
}
return true;
};
async function handleFeatureToggle({ feature, enabled }) {
try {
await captainConfigStore.updatePreferences({
@@ -144,9 +105,8 @@ onMounted(() => {
<div class="grid gap-4">
<ModelSelector
v-for="feature in modelFeatures"
v-show="shouldShowFeature(feature)"
:key="feature.key"
:is-allowed="isFeatureAccessible(feature)"
is-allowed
:feature-key="feature.key"
:title="feature.title"
:description="feature.description"
@@ -164,9 +124,8 @@ onMounted(() => {
<div class="grid gap-4">
<FeatureToggle
v-for="feature in featureToggles"
v-show="shouldShowFeature(feature)"
:key="feature.key"
:is-allowed="isFeatureAccessible(feature)"
is-allowed
:feature-key="feature.key"
@change="handleFeatureToggle"
@model-change="handleModelChange"
@@ -6,7 +6,6 @@ import { vOnClickOutside } from '@vueuse/components';
import { useVuelidate } from '@vuelidate/core';
import { minValue } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsAccordion from 'dashboard/components-next/Settings/SettingsAccordion.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -28,7 +27,6 @@ const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { isEnterprise } = useConfig();
const selectedAgentIds = ref([]);
const isAgentListUpdating = ref(false);
@@ -406,10 +404,7 @@ onMounted(() => {
:description="assignmentDescription"
@update:model-value="handleToggleAutoAssignment"
>
<template
v-if="enableAutoAssignment && (isEnterprise || hasAssignmentV2)"
#editor
>
<template v-if="enableAutoAssignment" #editor>
<!-- assignment_v2 UI -->
<template v-if="hasAssignmentV2">
<!-- Policy Card - When policy is attached -->
@@ -637,7 +632,7 @@ onMounted(() => {
</template>
<!-- Old UI for non-assignment_v2 -->
<template v-else-if="isEnterprise">
<template v-else>
<div class="p-4">
<woot-input
v-model="maxAssignmentLimit"
@@ -4,7 +4,6 @@ import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useRouter } from 'vue-router';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { differenceInDays } from 'date-fns';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useI18n } from 'vue-i18n';
@@ -23,7 +22,6 @@ const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { accountId, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const { isAdmin } = useAdmin();
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
@@ -103,7 +101,7 @@ const routeToBilling = () => {
};
onMounted(() => {
if (isEnterprise) {
if (isOnChatwootCloud.value) {
fetchLimits();
}
});
@@ -19,7 +19,6 @@ const {
LOGO: logo,
LOGO_DARK: logoDark,
PRIVACY_URL: privacyURL,
IS_ENTERPRISE: isEnterprise,
TERMS_URL: termsURL,
WIDGET_BRAND_URL: widgetBrandURL,
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
@@ -49,7 +48,6 @@ const state = {
privacyURL,
termsURL,
widgetBrandURL,
isEnterprise: parseBoolean(isEnterprise),
activePlatformBanners: activePlatformBanners || [],
};
@@ -11,7 +11,7 @@ const validateSSOLoginParams = to => {
return isLoginRoute && hasValidSSOParams;
};
export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
export const validateRouteAccess = (to, next) => {
// Pages with ignoreSession:true would be rendered
// even if there is an active session
// Used for confirmation or password reset pages
@@ -33,14 +33,7 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
return;
}
// Disable navigation to SAML login if enterprise is not enabled
// SAML route has an attribute (requireEnterprise) in it's definition
const isEnterpriseOnlyPath =
chatwootConfig.isEnterprise !== 'true' &&
to.meta &&
to.meta.requireEnterprise;
if (!to.name || isEnterpriseOnlyPath) {
if (!to.name) {
next(frontendURL('login'));
return;
}
@@ -57,6 +57,14 @@ describe('#validateRouteAccess', () => {
expect(next).toHaveBeenCalledWith('/app/login');
});
it('allows routes that were previously restricted to enterprise installs', () => {
validateRouteAccess(
{ name: 'saml_login', meta: { requireEnterprise: true } },
next
);
expect(next).toHaveBeenCalledWith();
});
it('continues to the route in every other case', () => {
validateRouteAccess({ name: 'reset_password' }, next);
expect(clearBrowserSessionCookies).not.toHaveBeenCalled();
+1 -1
View File
@@ -17,7 +17,7 @@ export const initalizeRouter = () => {
});
}
return validateRouteAccess(to, next, window.chatwootConfig);
return validateRouteAccess(to, next);
});
};
-1
View File
@@ -27,7 +27,6 @@
whatsappConfigurationId: '',
whatsappApiVersion: '',
signupEnabled: 'false',
isEnterprise: 'false',
isMfaEnabled: 'false',
inboxEventsEnabled: 'false',
selectedLocale: 'zh_CN',