feat: add global config for captain settings (#13141)
Co-authored-by: aakashb95 <aakashbakhle@gmail.com> Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
<script setup>
|
||||
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';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SectionLayout from '../account/components/SectionLayout.vue';
|
||||
import ModelSelector from './components/ModelSelector.vue';
|
||||
import FeatureToggle from './components/FeatureToggle.vue';
|
||||
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);
|
||||
|
||||
const isLoading = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const modelFeatures = computed(() => [
|
||||
{
|
||||
key: 'editor',
|
||||
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.EDITOR.TITLE'),
|
||||
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.EDITOR.DESCRIPTION'),
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
]);
|
||||
|
||||
const featureToggles = computed(() => [
|
||||
{
|
||||
key: 'label_suggestion',
|
||||
},
|
||||
{
|
||||
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({
|
||||
captain_features: { [feature]: enabled },
|
||||
});
|
||||
useAlert(t('CAPTAIN_SETTINGS.API.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CAPTAIN_SETTINGS.API.ERROR'));
|
||||
captainConfigStore.fetch();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModelChange({ feature, model }) {
|
||||
try {
|
||||
await captainConfigStore.updatePreferences({
|
||||
captain_models: { [feature]: model },
|
||||
});
|
||||
useAlert(t('CAPTAIN_SETTINGS.API.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CAPTAIN_SETTINGS.API.ERROR'));
|
||||
captainConfigStore.fetch();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
captainConfigStore.fetch();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="isLoading"
|
||||
:no-records-message="t('CAPTAIN_SETTINGS.NOT_ENABLED')"
|
||||
:loading-message="t('CAPTAIN_SETTINGS.LOADING')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="t('CAPTAIN_SETTINGS.TITLE')"
|
||||
:description="t('CAPTAIN_SETTINGS.DESCRIPTION')"
|
||||
:link-text="t('CAPTAIN_SETTINGS.LINK_TEXT')"
|
||||
icon-name="captain"
|
||||
feature-name="captain_billing"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="captainEnabled" class="flex flex-col gap-1">
|
||||
<!-- Model Configuration Section -->
|
||||
<SectionLayout
|
||||
:title="t('CAPTAIN_SETTINGS.MODEL_CONFIG.TITLE')"
|
||||
:description="t('CAPTAIN_SETTINGS.MODEL_CONFIG.DESCRIPTION')"
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<ModelSelector
|
||||
v-for="feature in modelFeatures"
|
||||
v-show="shouldShowFeature(feature)"
|
||||
:key="feature.key"
|
||||
:is-allowed="isFeatureAccessible(feature)"
|
||||
:feature-key="feature.key"
|
||||
:title="feature.title"
|
||||
:description="feature.description"
|
||||
@change="handleModelChange"
|
||||
/>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
|
||||
<!-- Features Section -->
|
||||
<SectionLayout
|
||||
:title="t('CAPTAIN_SETTINGS.FEATURES.TITLE')"
|
||||
:description="t('CAPTAIN_SETTINGS.FEATURES.DESCRIPTION')"
|
||||
with-border
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<FeatureToggle
|
||||
v-for="feature in featureToggles"
|
||||
v-show="shouldShowFeature(feature)"
|
||||
:key="feature.key"
|
||||
:is-allowed="isFeatureAccessible(feature)"
|
||||
:feature-key="feature.key"
|
||||
@change="handleFeatureToggle"
|
||||
@model-change="handleModelChange"
|
||||
/>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
</div>
|
||||
<div v-else>
|
||||
<CaptainPaywall />
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/captain'),
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
props: {
|
||||
headerTitle: 'CAPTAIN_SETTINGS.TITLE',
|
||||
icon: 'i-lucide-bot',
|
||||
showNewButton: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'captain_settings_index',
|
||||
component: Index,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import ModelDropdown from './ModelDropdown.vue';
|
||||
|
||||
const props = defineProps({
|
||||
featureKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isAllowed: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change', 'modelChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const captainConfigStore = useCaptainConfigStore();
|
||||
const { features } = storeToRefs(captainConfigStore);
|
||||
|
||||
const availableModels = computed(() =>
|
||||
captainConfigStore.getModelsForFeature(props.featureKey)
|
||||
);
|
||||
|
||||
const isEnabled = ref(false);
|
||||
|
||||
const featureConfig = computed(() => features.value[props.featureKey]);
|
||||
|
||||
const hasMultipleModels = computed(() => {
|
||||
return availableModels.value && availableModels.value.length > 1;
|
||||
});
|
||||
|
||||
const showModelSelector = computed(() => {
|
||||
return isEnabled.value && hasMultipleModels.value;
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.TITLE');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.TITLE');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.TITLE');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const description = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.DESCRIPTION');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.DESCRIPTION');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.DESCRIPTION');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const modelTitle = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.MODEL_TITLE');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_TITLE');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_TITLE');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const modelDescription = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.MODEL_DESCRIPTION');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_DESCRIPTION');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_DESCRIPTION');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
watch(
|
||||
featureConfig,
|
||||
newConfig => {
|
||||
if (newConfig !== undefined) {
|
||||
isEnabled.value = !!newConfig.enabled;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const toggleFeature = () => {
|
||||
emit('change', { feature: props.featureKey, enabled: isEnabled.value });
|
||||
};
|
||||
|
||||
const handleModelChange = ({ feature, model }) => {
|
||||
emit('modelChange', { feature, model });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="p-4 rounded-xl border border-n-weak bg-n-solid-1 flex"
|
||||
:class="{
|
||||
'flex-col gap-3': showModelSelector,
|
||||
'items-center justify-between gap-4': !showModelSelector,
|
||||
'opacity-60 pointer-events-none': !isAllowed,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4 flex-1">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">{{ title }}</h4>
|
||||
<p class="text-sm text-n-slate-11 mt-0.5">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="isAllowed" class="flex-shrink-0">
|
||||
<Switch v-model="isEnabled" @change="toggleFeature" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showModelSelector && isAllowed"
|
||||
class="flex gap-2 ps-8 relative before:content-[''] before:absolute before:w-0.5 before:h-1/2 before:top-0 before:start-3 before:bg-n-weak after:content-[''] after:absolute after:w-2.5 after:h-3 after:top-[calc(50%-6px)] after:start-3 after:border-b-[0.125rem] after:border-s-[0.125rem] after:rounded-es after:border-n-weak"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">{{ modelTitle }}</h4>
|
||||
<p class="text-sm text-n-slate-11 mt-0.5">{{ modelDescription }}</p>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<ModelDropdown :feature-key="featureKey" @change="handleModelChange" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import DropdownBody from 'dashboard/components-next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownItem from 'dashboard/components-next/dropdown-menu/base/DropdownItem.vue';
|
||||
import { provideDropdownContext } from 'dashboard/components-next/dropdown-menu/base/provider.js';
|
||||
|
||||
const props = defineProps({
|
||||
featureKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const PROVIDER_ICONS = {
|
||||
openai: 'i-ri-openai-fill',
|
||||
anthropic: 'i-ri-anthropic-line',
|
||||
mistral: 'i-logos-mistral-icon',
|
||||
gemini: 'i-woot-gemini',
|
||||
};
|
||||
|
||||
const iconForModel = model => {
|
||||
return PROVIDER_ICONS[model.provider];
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const captainConfigStore = useCaptainConfigStore();
|
||||
const isOpen = ref(false);
|
||||
|
||||
const availableModels = computed(() =>
|
||||
captainConfigStore.getModelsForFeature(props.featureKey)
|
||||
);
|
||||
|
||||
const recommendedModelId = computed(() =>
|
||||
captainConfigStore.getDefaultModelForFeature(props.featureKey)
|
||||
);
|
||||
|
||||
const selectedModel = computed(() =>
|
||||
captainConfigStore.getSelectedModelForFeature(props.featureKey)
|
||||
);
|
||||
|
||||
const selectedModelId = ref(null);
|
||||
|
||||
watch(
|
||||
selectedModel,
|
||||
newSelected => {
|
||||
if (newSelected) {
|
||||
selectedModelId.value = newSelected;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const selectedModelDetails = computed(() => {
|
||||
if (!selectedModelId.value) return null;
|
||||
return (
|
||||
availableModels.value.find(m => m.id === selectedModelId.value) || null
|
||||
);
|
||||
});
|
||||
|
||||
const getCreditLabel = model => {
|
||||
const multiplier = model.credit_multiplier || 1;
|
||||
return t('CAPTAIN_SETTINGS.MODEL_CONFIG.CREDITS_PER_MESSAGE', {
|
||||
credits: multiplier,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
provideDropdownContext({
|
||||
isOpen,
|
||||
toggle: () => toggleDropdown(),
|
||||
closeMenu: closeDropdown,
|
||||
});
|
||||
|
||||
const selectModel = model => {
|
||||
selectedModelId.value = model.id;
|
||||
emit('change', { feature: props.featureKey, model: model.id });
|
||||
closeDropdown();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeDropdown" class="relative flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm border rounded-lg border-n-weak dark:bg-n-solid-2 dark:hover:bg-n-solid-3 bg-n-alpha-2 hover:bg-n-alpha-1 min-w-[180px] justify-between"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span v-if="selectedModelDetails" class="text-n-slate-12">
|
||||
{{ selectedModelDetails.display_name }}
|
||||
</span>
|
||||
<span v-else class="text-n-slate-10">
|
||||
{{ t('CAPTAIN_SETTINGS.MODEL_CONFIG.SELECT_MODEL') }}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="size-4 text-n-slate-11 transition-transform"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</button>
|
||||
<DropdownBody
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 top-full mt-1 min-w-64 z-50 max-h-96 [&>ul]:max-h-96 [&>ul]:overflow-y-scroll"
|
||||
>
|
||||
<DropdownItem
|
||||
v-for="model in availableModels"
|
||||
:key="model.id"
|
||||
:click="() => selectModel(model)"
|
||||
class="rounded-lg dark:hover:bg-n-solid-3 hover:bg-n-alpha-1"
|
||||
:class="{
|
||||
'dark:bg-n-solid-3 bg-n-alpha-1': selectedModelId === model.id,
|
||||
'pointer-events-none opacity-60': model.coming_soon,
|
||||
}"
|
||||
>
|
||||
<div class="flex gap-2 w-full">
|
||||
<Icon :icon="iconForModel(model)" class="size-4 flex-shrink-0" />
|
||||
<div class="flex flex-col w-full text-left gap-1">
|
||||
<div
|
||||
class="text-sm w-full font-medium leading-none text-n-slate-12 flex items-baseline justify-between"
|
||||
>
|
||||
{{ model.display_name }}
|
||||
<span
|
||||
v-if="model.id === recommendedModelId"
|
||||
class="text-[10px] uppercase text-n-iris-11 border border-1 border-n-iris-10 leading-none rounded-lg px-1 py-0.5"
|
||||
>
|
||||
{{ t('GENERAL.PREFERRED') }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="model.coming_soon" class="text-xs text-n-slate-11">
|
||||
{{ t('CAPTAIN_SETTINGS.MODEL_CONFIG.COMING_SOON') }}
|
||||
</span>
|
||||
<span v-else-if="isOnChatwootCloud" class="text-xs text-n-slate-11">
|
||||
{{ getCreditLabel(model) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownBody>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import ModelDropdown from './ModelDropdown.vue';
|
||||
|
||||
defineProps({
|
||||
featureKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isAllowed: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const handleModelChange = ({ feature, model }) => {
|
||||
emit('change', { feature, model });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 p-4 rounded-xl border border-n-weak bg-n-solid-1"
|
||||
:class="{ 'opacity-60 pointer-events-none relative': !isAllowed }"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</h4>
|
||||
<p class="text-sm text-n-slate-11 mt-0.5">{{ description }}</p>
|
||||
</div>
|
||||
<ModelDropdown
|
||||
v-if="isAllowed"
|
||||
:feature-key="featureKey"
|
||||
@change="handleModelChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -24,6 +24,7 @@ import teams from './teams/teams.routes';
|
||||
import customRoles from './customRoles/customRole.routes';
|
||||
import profile from './profile/profile.routes';
|
||||
import security from './security/security.routes';
|
||||
import captain from './captain/captain.routes';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -63,5 +64,6 @@ export default {
|
||||
...customRoles.routes,
|
||||
...profile.routes,
|
||||
...security.routes,
|
||||
...captain.routes,
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user