Feature flags (#543)

* Re-organize a bit controllers

* Added the featureflagcontroller

* Implement feature flags in the front-end

* Clean env files

* Clean console.log messages

* Fix feature flag test
This commit is contained in:
Julien Nahum 2024-08-27 16:49:43 +02:00 committed by GitHub
parent 1dffd27390
commit 79d3dd7888
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 304 additions and 147 deletions

View File

@ -4,6 +4,8 @@ APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
SELF_HOSTED=true
LOG_CHANNEL=errorlog
LOG_LEVEL=debug
@ -44,4 +46,3 @@ MUX_WORKSPACE_ID=
MUX_API_TOKEN=
OPEN_AI_API_KEY=
SELF_HOSTED=true

View File

@ -5,6 +5,8 @@ APP_DEBUG=true
APP_LOG_LEVEL=debug
APP_URL=http://localhost
SELF_HOSTED=true
LOG_CHANNEL=stack
LOG_LEVEL=debug
@ -87,3 +89,5 @@ GOOGLE_REDIRECT_URL=http://localhost:3000/settings/connections/callback/google
GOOGLE_AUTH_REDIRECT_URL=http://localhost:3000/oauth/google/callback
GOOGLE_FONTS_API_KEY=
ZAPIER_ENABLED=false

View File

@ -1,11 +1,12 @@
<?php
namespace App\Http\Controllers;
namespace App\Http\Controllers\Auth;
use App\Models\UserInvite;
use App\Models\Workspace;
use App\Service\WorkspaceHelper;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class UserInviteController extends Controller
{
@ -31,7 +32,7 @@ class UserInviteController extends Controller
return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']);
}
if($userInvite->status == UserInvite::ACCEPTED_STATUS) {
if ($userInvite->status == UserInvite::ACCEPTED_STATUS) {
return $this->error(['success' => false, 'message' => 'Invite already accepted.']);
}
@ -49,7 +50,7 @@ class UserInviteController extends Controller
return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']);
}
if($userInvite->status == UserInvite::ACCEPTED_STATUS) {
if ($userInvite->status == UserInvite::ACCEPTED_STATUS) {
return $this->error(['success' => false, 'message' => 'Invite already accepted.']);
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Content;
use App\Http\Controllers\Controller;
class FeatureFlagsController extends Controller
{
public function index()
{
$featureFlags = \Cache::remember('feature_flags', 3600, function () {
return [
'self_hosted' => config('app.self_hosted', true),
'custom_domains' => config('custom_domains.enabled', false),
'ai_features' => !empty(config('services.openai.api_key')),
'billing' => [
'enabled' => !empty(config('cashier.key')) && !empty(config('cashier.secret')),
'appsumo' => !empty(config('services.appsumo.api_key')) && !empty(config('services.appsumo.api_secret')),
],
'storage' => [
'local' => config('filesystems.default') === 'local',
's3' => config('filesystems.default') !== 'local',
],
'services' => [
'unsplash' => !empty(config('services.unsplash.access_key')),
'google' => [
'fonts' => !empty(config('services.google.fonts_api_key')),
'auth' => !empty(config('services.google.client_id')) && !empty(config('services.google.client_secret')),
],
],
'integrations' => [
'zapier' => config('services.zapier.enabled'),
'google_sheets' => !empty(config('services.google.client_id')) && !empty(config('services.google.client_secret')),
],
];
});
return response()->json($featureFlags);
}
}

View File

@ -1,14 +1,19 @@
<?php
namespace App\Http\Controllers;
namespace App\Http\Controllers\Content;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use App\Http\Controllers\Controller;
class FontsController extends Controller
{
public function index(Request $request)
{
if (!config('services.google.fonts_api_key')) {
return response()->json([]);
}
return \Cache::remember('google_fonts', 60 * 60, function () {
$url = "https://www.googleapis.com/webfonts/v1/webfonts?sort=popularity&key=" . config('services.google.fonts_api_key');
$response = Http::get($url);

View File

@ -1,9 +1,10 @@
<?php
namespace App\Http\Controllers;
namespace App\Http\Controllers\Content;
use App\Models\Template;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class SitemapController extends Controller
{
@ -20,7 +21,7 @@ class SitemapController extends Controller
Template::where('publicly_listed', true)->chunk(100, function ($templates) use (&$urls) {
foreach ($templates as $template) {
$urls[] = [
'loc' => '/templates/'.$template->slug,
'loc' => '/templates/' . $template->slug,
];
}
});

View File

@ -1,12 +1,13 @@
<?php
namespace App\Http\Controllers;
namespace App\Http\Controllers\Forms;
use App\Http\Requests\Templates\FormTemplateRequest;
use App\Http\Resources\FormTemplateResource;
use App\Models\Template;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\Controller;
class TemplateController extends Controller
{

View File

@ -74,4 +74,8 @@ return [
'fonts_api_key' => env('GOOGLE_FONTS_API_KEY'),
],
'zapier' => [
'enabled' => env('ZAPIER_ENABLED', false),
],
];

View File

@ -20,8 +20,8 @@ use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\TokenController;
use App\Http\Controllers\SubscriptionController;
use App\Http\Controllers\TemplateController;
use App\Http\Controllers\UserInviteController;
use App\Http\Controllers\Forms\TemplateController;
use App\Http\Controllers\Auth\UserInviteController;
use App\Http\Controllers\WorkspaceController;
use App\Http\Controllers\WorkspaceUserController;
use App\Http\Middleware\Form\ResolveFormMiddleware;
@ -309,13 +309,14 @@ Route::prefix('forms')->name('forms.')->group(function () {
* Other public routes
*/
Route::prefix('content')->name('content.')->group(function () {
Route::get('/feature-flags', [\App\Http\Controllers\Content\FeatureFlagsController::class, 'index'])->name('feature-flags');
Route::get('changelog/entries', [\App\Http\Controllers\Content\ChangelogController::class, 'index'])->name('changelog.entries');
});
Route::get('/sitemap-urls', [\App\Http\Controllers\SitemapController::class, 'index'])->name('sitemap.index');
Route::get('/sitemap-urls', [\App\Http\Controllers\Content\SitemapController::class, 'index'])->name('sitemap.index');
// Fonts
Route::get('/fonts', [\App\Http\Controllers\FontsController::class, 'index'])->name('fonts.index');
Route::get('/fonts', [\App\Http\Controllers\Content\FontsController::class, 'index'])->name('fonts.index');
// Templates
Route::prefix('templates')->group(function () {

View File

@ -0,0 +1,69 @@
<?php
use App\Http\Controllers\Content\FeatureFlagsController;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
it('returns feature flags', function () {
// Arrange
Config::set('app.self_hosted', false);
Config::set('custom_domains.enabled', true);
Config::set('cashier.key', 'stripe_key');
Config::set('cashier.secret', 'stripe_secret');
Config::set('services.appsumo.api_key', 'appsumo_key');
Config::set('services.appsumo.api_secret', 'appsumo_secret');
Config::set('filesystems.default', 's3');
Config::set('services.openai.api_key', 'openai_key');
Config::set('services.unsplash.access_key', 'unsplash_key');
Config::set('services.google.fonts_api_key', 'google_fonts_key');
Config::set('services.google.client_id', 'google_client_id');
Config::set('services.google.client_secret', 'google_client_secret');
Config::set('services.zapier.enabled', true);
// Act
$response = $this->getJson(route('content.feature-flags'));
// Assert
$response->assertStatus(200)
->assertJson([
'self_hosted' => false,
'custom_domains' => true,
'ai_features' => true,
'billing' => [
'enabled' => true,
'appsumo' => true,
],
'storage' => [
'local' => false,
's3' => true,
],
'services' => [
'unsplash' => true,
'google' => [
'fonts' => true,
'auth' => true,
],
],
'integrations' => [
'zapier' => true,
'google_sheets' => true,
],
]);
});
it('caches feature flags', function () {
// Arrange
Cache::shouldReceive('remember')
->once()
->withArgs(function ($key, $ttl, $callback) {
return $key === 'feature_flags' && $ttl === 3600 && is_callable($callback);
})
->andReturn(['some' => 'data']);
// Act
$controller = new FeatureFlagsController();
$response = $controller->index();
// Assert
$this->assertEquals(['some' => 'data'], $response->getData(true));
});

View File

@ -1,8 +1,4 @@
NUXT_PUBLIC_APP_URL=/
NUXT_PUBLIC_API_BASE=/api
NUXT_PRIVATE_API_BASE=http://ingress/api
NUXT_PUBLIC_AI_FEATURES_ENABLED=false
NUXT_PUBLIC_ENV=dev
NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE=
NUXT_PUBLIC_H_CAPTCHA_SITE_KEY=
NUXT_PUBLIC_S3_ENABLED=false

View File

@ -1,13 +1,9 @@
NUXT_LOG_LEVEL=
NUXT_PUBLIC_APP_URL=
NUXT_PUBLIC_API_BASE=
NUXT_PUBLIC_AI_FEATURES_ENABLED=
NUXT_PUBLIC_AMPLITUDE_CODE=
NUXT_PUBLIC_CRISP_WEBSITE_ID=
NUXT_PUBLIC_CUSTOM_DOMAINS_ENABLED=
NUXT_PUBLIC_ENV=
NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE=
NUXT_PUBLIC_H_CAPTCHA_SITE_KEY=
NUXT_PUBLIC_PAID_PLANS_ENABLED=
NUXT_PUBLIC_S3_ENABLED=
NUXT_API_SECRET=secret

View File

@ -71,7 +71,7 @@
:max-date="maxDate"
:is-dark="props.isDark"
color="form-color"
@update:modelValue="updateModelValue"
@update:model-value="updateModelValue"
/>
<DatePicker
v-else
@ -84,7 +84,7 @@
:max-date="maxDate"
:is-dark="props.isDark"
color="form-color"
@update:modelValue="updateModelValue"
@update:model-value="updateModelValue"
/>
</template>
</UPopover>
@ -201,7 +201,7 @@ const formattedDate = (value) => {
try {
return format(new Date(value), props.dateFormat + (props.timeFormat == 12 ? ' p':' HH:mm'))
} catch (e) {
console.log(e)
console.log('Error formatting date', e)
return ''
}
}

View File

@ -54,7 +54,7 @@
</a>
</template>
<NuxtLink
v-if="($route.name !== 'ai-form-builder' && user === null) && (!appStore.selfHosted || appStore.aiFeaturesEnabled)"
v-if="($route.name !== 'ai-form-builder' && user === null) && (!useFeatureFlag('self_hosted') || useFeatureFlag('ai_features'))"
:to="{ name: 'ai-form-builder' }"
:class="navLinkClasses"
class="hidden lg:inline"
@ -63,9 +63,9 @@
</NuxtLink>
<NuxtLink
v-if="
(appStore.paidPlansEnabled &&
(useFeatureFlag('billing.enabled') &&
(user === null || (user && workspace && !workspace.is_pro)) &&
$route.name !== 'pricing') && !appStore.selfHosted
$route.name !== 'pricing') && !isSelfHosted
"
:to="{ name: 'pricing' }"
:class="navLinkClasses"
@ -248,7 +248,7 @@
</NuxtLink>
<v-button
v-if="!appStore.selfHosted"
v-if="!isSelfHosted"
v-track.nav_create_form_click
size="small"
class="shrink-0"
@ -274,6 +274,7 @@ import Dropdown from "~/components/global/Dropdown.vue"
import WorkspaceDropdown from "./WorkspaceDropdown.vue"
import opnformConfig from "~/opnform.config.js"
import { useRuntimeConfig } from "#app"
import { useFeatureFlag } from "~/composables/useFeatureFlag"
export default {
components: {
@ -294,6 +295,7 @@ export default {
config: useRuntimeConfig(),
user: computed(() => authStore.user),
isIframe: useIsIframe(),
isSelfHosted: computed(() => useFeatureFlag('self_hosted')),
}
},

View File

@ -33,7 +33,7 @@ const user = computed(() => authStore.user)
const workspace = computed(() => workspacesStore.getCurrent)
const shouldDisplayProTag = computed(() => {
if (!useRuntimeConfig().public.paidPlansEnabled) return false
if (!useFeatureFlag('billing.enabled')) return false
if (!user.value || !workspace.value) return true
return !workspace.value.is_pro
})

View File

@ -1,18 +1,28 @@
<template>
<ErrorBoundary @on-error="onFormEditorError">
<template #error="{ error, clearError }">
<div class="flex-grow w-full flex items-center justify-center flex-col gap-4">
<h1 class="text-blue-800 text-2xl font-medium">Oops! Something went wrong.</h1>
<p class="text-gray-500 max-w-lg text-center">It looks like your last action caused an issue on our side. We
<h1 class="text-blue-800 text-2xl font-medium">
Oops! Something went wrong.
</h1>
<p class="text-gray-500 max-w-lg text-center">
It looks like your last action caused an issue on our side. We
apologize for
the
inconvenience.</p>
inconvenience.
</p>
<div class="flex gap-2 mt-4">
<UButton icon="i-material-symbols-undo" @click="clearEditorError(error, clearError)">Go back one step
<UButton
icon="i-material-symbols-undo"
@click="clearEditorError(error, clearError)"
>
Go back one step
</UButton>
<UButton variant="outline" icon="i-heroicons-chat-bubble-left-right-16-solid"
@click="onErrorContact(error)">
<UButton
variant="outline"
icon="i-heroicons-chat-bubble-left-right-16-solid"
@click="onErrorContact(error)"
>
Report this error
</UButton>
</div>
@ -21,7 +31,7 @@
<slot />
</ErrorBoundary>
</template>
</template>
<script setup>
import { computed } from 'vue'
@ -51,7 +61,6 @@
}
}
const onErrorContact = (error) => {
console.log('Contacting via crisp for an error', error)
crisp.pauseChatBot()
let errorReport = 'Hi there, I have a technical issue with the form editor.'
if (form.value.slug) {

View File

@ -25,7 +25,7 @@
on other sites (Open Graph).
</p>
<select-input
v-if="customDomainAllowed"
v-if="useFeatureFlag('custom_domains')"
v-model="form.custom_domain"
:clearable="true"
:disabled="customDomainOptions.length <= 0"
@ -98,9 +98,6 @@ export default {
})
: []
},
customDomainAllowed() {
return useRuntimeConfig().public.customDomainsEnabled
},
},
watch: {},
mounted() {

View File

@ -53,6 +53,7 @@
label="Form Theme"
/>
<template v-if="useFeatureFlag('services.google.fonts')">
<label class="text-gray-700 font-medium text-sm">Font Style</label>
<v-button
color="white"
@ -70,6 +71,7 @@
@close="showGoogleFontPicker=false"
@apply="onApplyFont"
/>
</template>
<div class="flex space-x-4 justify-stretch">
<select-input
@ -216,7 +218,7 @@ const user = computed(() => authStore.user)
const workspace = computed(() => workspacesStore.getCurrent)
const isPro = computed(() => {
if (!useRuntimeConfig().public.paidPlansEnabled) return true
if (!useFeatureFlag('billing.enabled')) return true
if (!user.value || !workspace.value) return false
return workspace.value.is_pro
})
@ -237,7 +239,6 @@ const onChangeNoBranding = (val) => {
subscriptionModalStore.openModal()
setTimeout(() => {
form.value.no_branding = false
console.log("form.value.no_branding", form.value.no_branding)
}, 300)
}
}

View File

@ -21,8 +21,8 @@
</div>
<div class="flex justify-center mt-5 md:mt-0">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-4 gap-y-2">
<template v-if="!useFeatureFlag('self_hosted')">
<router-link
v-if="!appStore.selfHosted"
:to="{ name: 'privacy-policy' }"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>
@ -30,12 +30,12 @@
</router-link>
<router-link
v-if="!appStore.selfHosted"
:to="{ name: 'terms-conditions' }"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>
Terms & Conditions
</router-link>
</template>
<a
:href="opnformConfig.links.feature_requests"
target="_blank"

View File

@ -59,6 +59,7 @@
</v-button>
<v-button
v-if="useFeatureFlag('services.google.auth')"
native-type="button"
color="white"
class="space-x-4 mt-4 flex items-center w-full"
@ -72,7 +73,7 @@
<span class="mx-2">Sign in with Google</span>
</v-button>
<p
v-if="!appStore.selfHosted"
v-if="!useFeatureFlag('self_hosted')"
class="text-gray-500 text-sm text-center mt-4"
>
Don't have an account?

View File

@ -87,6 +87,7 @@
Create account
</v-button>
<template v-if="useFeatureFlag('services.google.auth')">
<p class="text-gray-600/50 text-sm text-center my-4">
Or
</p>
@ -100,6 +101,7 @@
<Icon name="devicon:google" />
<span class="mx-2">Sign in with Google</span>
</v-button>
</template>
<p class="text-gray-500 mt-4 text-sm text-center">
Already have an account?

View File

@ -72,7 +72,7 @@
</p>
</div>
<div
v-if="aiFeaturesEnabled"
v-if="useFeatureFlag('ai_features')"
v-track.select_form_base="{ base: 'ai' }"
class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50"
role="button"
@ -185,12 +185,6 @@ export default {
loading: false,
}),
computed: {
aiFeaturesEnabled() {
return this.runtimeConfig.public.aiFeaturesEnabled
},
},
methods: {
generateForm() {
if (this.loading) return

View File

@ -1,6 +1,6 @@
<template>
<div
v-if="customDomainsEnabled"
v-if="useFeatureFlag('custom_domains')"
id="custom-domains"
>
<UButton
@ -82,10 +82,6 @@ const customDomainsForm = useForm({
const customDomainsLoading = ref(false)
const showCustomDomainModal = ref(false)
const customDomainsEnabled = computed(
() => useRuntimeConfig().public.customDomainsEnabled,
)
onMounted(() => {
initCustomDomains()
})

View File

@ -202,7 +202,7 @@ const showEditUserModal = ref(false)
const selectedUser = ref(null)
const userNewRole = ref("")
const paidPlansEnabled = computed(() => useRuntimeConfig().public.paidPlansEnabled)
const paidPlansEnabled = ref(useFeatureFlag('billing.enabled'))
const canInviteUser = computed(() => {
return paidPlansEnabled.value ? workspace.value.is_pro : true
})

5
client/composables/useFeatureFlag.js vendored Normal file
View File

@ -0,0 +1,5 @@
export function useFeatureFlag(flagName, defaultValue = null) {
const featureStore = useFeatureFlagsStore()
return featureStore.getFlag(flagName, defaultValue)
}

View File

@ -10,7 +10,7 @@ async function storeLocalFile(file) {
}
export const storeFile = async (file, options = {}) => {
if (!useRuntimeConfig().public.s3Enabled)
if (useFeatureFlag('storage.local'))
return storeLocalFile(file, options)
const response = await opnFetch(

View File

@ -83,7 +83,6 @@ export function darkModeEnabled (elem = ref(null)) {
// Update isDark based on the current class
const updateIsDark = () => {
console.log(elem.value, 'test')
const finalElement = elem.value ?? document.body
isDark.value = finalElement.classList.contains('dark')
}

2
client/lib/utils.js vendored
View File

@ -103,7 +103,7 @@ export const getDomain = function (url) {
*/
export const customDomainUsed = function () {
const config = useRuntimeConfig()
if (!config.public.customDomainsEnabled) return false
if (!useFeatureFlag('custom_domains')) return false
const appDomain = getDomain(config.public.appUrl)
const host = getHost()

View File

@ -47,7 +47,7 @@ export default defineNuxtRouteMiddleware((to) => {
})
}
if (!config.public.customDomainsEnabled) {
if (!useFeatureFlag('custom_domains')) {
// If custom domain not allowed, redirect
return redirectToMainDomain({
reason: "custom_domains_disabled",

View File

@ -0,0 +1,9 @@
import { useFeatureFlagsStore } from '~/stores/featureFlags'
export default defineNuxtRouteMiddleware(async () => {
const featureFlagsStore = useFeatureFlagsStore()
if (import.meta.server && Object.keys(featureFlagsStore.flags).length === 0) {
await featureFlagsStore.fetchFlags()
}
})

View File

@ -2,7 +2,7 @@ export default defineNuxtRouteMiddleware(() => {
const authStore = useAuthStore()
const runtimeConfig = useRuntimeConfig()
if (runtimeConfig.public?.selfHosted) {
if (useFeatureFlag('self_hosted')) {
if (authStore.check && authStore.user?.email === 'admin@opnform.com') {
return navigateTo({ name: "update-credentials" })
}

View File

@ -1,11 +1,10 @@
export default defineNuxtRouteMiddleware((from, to, next) => {
const runtimeConfig = useRuntimeConfig()
const route = useRoute()
if (runtimeConfig.public?.selfHosted) {
if (useFeatureFlag('self_hosted')) {
if (from.name === 'register' && route.query?.email && route.query?.invite_token) {
return
}
if (from.name === 'ai-form-builder' && runtimeConfig.public?.aiFeaturesEnabled) {
if (from.name === 'ai-form-builder' && useFeatureFlag('ai_features')) {
return
}
return navigateTo({ name: "index" })

View File

@ -176,7 +176,7 @@
<more-features class="pt-56" />
<pricing-table
v-if="paidPlansEnabled"
v-if="useFeatureFlag('billing.enabled')"
class="pb-20"
:home-page="true"
>
@ -337,9 +337,6 @@ export default {
configLinks() {
return this.config.links
},
paidPlansEnabled() {
return this.runtimeConfig.public.paidPlansEnabled
},
},
}
</script>

View File

@ -13,7 +13,7 @@
<p class="text-gray-500 text-sm">
Sign up in less than 2 minutes.
</p>
<template v-if="!appStore.selfHosted || isInvited">
<template v-if="!useFeatureFlag('self_hosted') || isInvited">
<register-form />
</template>
<div

View File

@ -9,7 +9,7 @@
You can switch to another workspace in top left corner of the page.</small>
</div>
<div class="w-full flex flex-wrap justify-between gap-2">
<WorkSpaceCustomDomains v-if="customDomainsEnabled && !loading" />
<WorkSpaceCustomDomains v-if="useFeatureFlag('custom_domains') && !loading" />
<UButton
label="New Workspace"
icon="i-heroicons-plus"
@ -116,9 +116,6 @@ const form = useForm({
const workspaceModal = ref(false)
const workspace = computed(() => workspacesStore.getCurrent)
const customDomainsEnabled = computed(
() => useRuntimeConfig().public.customDomainsEnabled,
)
onMounted(() => {
fetchAllWorkspaces()

9
client/plugins/featureFlags.js vendored Normal file
View File

@ -0,0 +1,9 @@
import { useFeatureFlagsStore } from '~/stores/featureFlags'
export default defineNuxtPlugin((nuxtApp) => {
const featureFlagsStore = useFeatureFlagsStore()
nuxtApp.provide('featureFlag', (key, defaultValue = false) => {
return featureFlagsStore.getFlag(key, defaultValue)
})
})

View File

@ -22,12 +22,8 @@ export default {
gtmCode: process.env.NUXT_PUBLIC_GTM_CODE || null,
amplitudeCode: process.env.NUXT_PUBLIC_AMPLITUDE_CODE || null,
crispWebsiteId: process.env.NUXT_PUBLIC_CRISP_WEBSITE_ID || null,
aiFeaturesEnabled: parseBoolean(process.env.NUXT_PUBLIC_AI_FEATURES_ENABLED),
s3Enabled: parseBoolean(process.env.NUXT_PUBLIC_S3_ENABLED),
paidPlansEnabled: parseBoolean(process.env.NUXT_PUBLIC_PAID_PLANS_ENABLED),
customDomainsEnabled: parseBoolean(process.env.NUXT_PUBLIC_CUSTOM_DOMAINS_ENABLED),
featureBaseOrganization: process.env.NUXT_PUBLIC_FEATURE_BASE_ORGANISATION || null,
selfHosted: parseBoolean(process.env.NUXT_PUBLIC_SELF_HOSTED, true),
// Config within public will be also exposed to the client
SENTRY_DSN_PUBLIC: process.env.SENTRY_DSN_PUBLIC,

View File

@ -21,10 +21,7 @@ export const useAppStore = defineStore("app", {
},
}),
getters: {
paidPlansEnabled: () => useRuntimeConfig().public.paidPlansEnabled,
featureBaseEnabled: () => useRuntimeConfig().public.featureBaseOrganization !== null,
selfHosted: () => useRuntimeConfig().public.selfHosted,
aiFeaturesEnabled: () => useRuntimeConfig().public.aiFeaturesEnabled,
crispEnabled: () => useRuntimeConfig().public.crispWebsiteId !== null && useRuntimeConfig().public.crispWebsiteId !== '',
},
actions: {

24
client/stores/featureFlags.js vendored Normal file
View File

@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useFeatureFlagsStore = defineStore('feature_flags', () => {
const flags = ref({})
async function fetchFlags() {
try {
const { data } = await useOpnApi('/content/feature-flags')
flags.value = data.value
} catch (error) {
console.error('Failed to fetch feature flags:', error)
}
}
function getFlag(path, defaultValue = false) {
return path.split('.').reduce((acc, part) => {
if (acc === undefined) return defaultValue
return acc && acc[part] !== undefined ? acc[part] : defaultValue
}, flags.value)
}
return { flags, fetchFlags, getFlag }
})

View File

@ -10,16 +10,19 @@ export const useFormIntegrationsStore = defineStore("form_integrations", () => {
const availableIntegrations = computed(() => {
const user = useAuthStore().user
const featureFlagsStore = useFeatureFlagsStore()
if (!user) return integrations.value
const enrichedIntegrations = new Map()
for (const [key, integration] of integrations.value.entries()) {
if (featureFlagsStore.getFlag(`integrations.${key}`, true)) {
enrichedIntegrations.set(key, {
...integration,
id: key,
requires_subscription: !user.is_subscribed && integration.is_pro,
})
}
}
return enrichedIntegrations
})