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_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
SELF_HOSTED=true
LOG_CHANNEL=errorlog LOG_CHANNEL=errorlog
LOG_LEVEL=debug LOG_LEVEL=debug
@ -44,4 +46,3 @@ MUX_WORKSPACE_ID=
MUX_API_TOKEN= MUX_API_TOKEN=
OPEN_AI_API_KEY= OPEN_AI_API_KEY=
SELF_HOSTED=true

View File

@ -5,6 +5,8 @@ APP_DEBUG=true
APP_LOG_LEVEL=debug APP_LOG_LEVEL=debug
APP_URL=http://localhost APP_URL=http://localhost
SELF_HOSTED=true
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_LEVEL=debug 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_AUTH_REDIRECT_URL=http://localhost:3000/oauth/google/callback
GOOGLE_FONTS_API_KEY= GOOGLE_FONTS_API_KEY=
ZAPIER_ENABLED=false

View File

@ -1,11 +1,12 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers\Auth;
use App\Models\UserInvite; use App\Models\UserInvite;
use App\Models\Workspace; use App\Models\Workspace;
use App\Service\WorkspaceHelper; use App\Service\WorkspaceHelper;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class UserInviteController extends Controller class UserInviteController extends Controller
{ {

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

View File

@ -1,9 +1,10 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers\Content;
use App\Models\Template; use App\Models\Template;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class SitemapController extends Controller class SitemapController extends Controller
{ {

View File

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

View File

@ -74,4 +74,8 @@ return [
'fonts_api_key' => env('GOOGLE_FONTS_API_KEY'), '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\ProfileController;
use App\Http\Controllers\Settings\TokenController; use App\Http\Controllers\Settings\TokenController;
use App\Http\Controllers\SubscriptionController; use App\Http\Controllers\SubscriptionController;
use App\Http\Controllers\TemplateController; use App\Http\Controllers\Forms\TemplateController;
use App\Http\Controllers\UserInviteController; use App\Http\Controllers\Auth\UserInviteController;
use App\Http\Controllers\WorkspaceController; use App\Http\Controllers\WorkspaceController;
use App\Http\Controllers\WorkspaceUserController; use App\Http\Controllers\WorkspaceUserController;
use App\Http\Middleware\Form\ResolveFormMiddleware; use App\Http\Middleware\Form\ResolveFormMiddleware;
@ -309,13 +309,14 @@ Route::prefix('forms')->name('forms.')->group(function () {
* Other public routes * Other public routes
*/ */
Route::prefix('content')->name('content.')->group(function () { 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('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 // 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 // Templates
Route::prefix('templates')->group(function () { 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_APP_URL=/
NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_API_BASE=/api
NUXT_PRIVATE_API_BASE=http://ingress/api NUXT_PRIVATE_API_BASE=http://ingress/api
NUXT_PUBLIC_AI_FEATURES_ENABLED=false
NUXT_PUBLIC_ENV=dev 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_LOG_LEVEL=
NUXT_PUBLIC_APP_URL= NUXT_PUBLIC_APP_URL=
NUXT_PUBLIC_API_BASE= NUXT_PUBLIC_API_BASE=
NUXT_PUBLIC_AI_FEATURES_ENABLED=
NUXT_PUBLIC_AMPLITUDE_CODE= NUXT_PUBLIC_AMPLITUDE_CODE=
NUXT_PUBLIC_CRISP_WEBSITE_ID= NUXT_PUBLIC_CRISP_WEBSITE_ID=
NUXT_PUBLIC_CUSTOM_DOMAINS_ENABLED=
NUXT_PUBLIC_ENV= NUXT_PUBLIC_ENV=
NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE= NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE=
NUXT_PUBLIC_H_CAPTCHA_SITE_KEY= NUXT_PUBLIC_H_CAPTCHA_SITE_KEY=
NUXT_PUBLIC_PAID_PLANS_ENABLED=
NUXT_PUBLIC_S3_ENABLED=
NUXT_API_SECRET=secret NUXT_API_SECRET=secret

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -202,7 +202,7 @@ const showEditUserModal = ref(false)
const selectedUser = ref(null) const selectedUser = ref(null)
const userNewRole = ref("") const userNewRole = ref("")
const paidPlansEnabled = computed(() => useRuntimeConfig().public.paidPlansEnabled) const paidPlansEnabled = ref(useFeatureFlag('billing.enabled'))
const canInviteUser = computed(() => { const canInviteUser = computed(() => {
return paidPlansEnabled.value ? workspace.value.is_pro : true 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 = {}) => { export const storeFile = async (file, options = {}) => {
if (!useRuntimeConfig().public.s3Enabled) if (useFeatureFlag('storage.local'))
return storeLocalFile(file, options) return storeLocalFile(file, options)
const response = await opnFetch( const response = await opnFetch(

View File

@ -83,7 +83,6 @@ export function darkModeEnabled (elem = ref(null)) {
// Update isDark based on the current class // Update isDark based on the current class
const updateIsDark = () => { const updateIsDark = () => {
console.log(elem.value, 'test')
const finalElement = elem.value ?? document.body const finalElement = elem.value ?? document.body
isDark.value = finalElement.classList.contains('dark') 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 () { export const customDomainUsed = function () {
const config = useRuntimeConfig() const config = useRuntimeConfig()
if (!config.public.customDomainsEnabled) return false if (!useFeatureFlag('custom_domains')) return false
const appDomain = getDomain(config.public.appUrl) const appDomain = getDomain(config.public.appUrl)
const host = getHost() 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 // If custom domain not allowed, redirect
return redirectToMainDomain({ return redirectToMainDomain({
reason: "custom_domains_disabled", 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 authStore = useAuthStore()
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
if (runtimeConfig.public?.selfHosted) { if (useFeatureFlag('self_hosted')) {
if (authStore.check && authStore.user?.email === 'admin@opnform.com') { if (authStore.check && authStore.user?.email === 'admin@opnform.com') {
return navigateTo({ name: "update-credentials" }) return navigateTo({ name: "update-credentials" })
} }

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@
You can switch to another workspace in top left corner of the page.</small> You can switch to another workspace in top left corner of the page.</small>
</div> </div>
<div class="w-full flex flex-wrap justify-between gap-2"> <div class="w-full flex flex-wrap justify-between gap-2">
<WorkSpaceCustomDomains v-if="customDomainsEnabled && !loading" /> <WorkSpaceCustomDomains v-if="useFeatureFlag('custom_domains') && !loading" />
<UButton <UButton
label="New Workspace" label="New Workspace"
icon="i-heroicons-plus" icon="i-heroicons-plus"
@ -116,9 +116,6 @@ const form = useForm({
const workspaceModal = ref(false) const workspaceModal = ref(false)
const workspace = computed(() => workspacesStore.getCurrent) const workspace = computed(() => workspacesStore.getCurrent)
const customDomainsEnabled = computed(
() => useRuntimeConfig().public.customDomainsEnabled,
)
onMounted(() => { onMounted(() => {
fetchAllWorkspaces() 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, gtmCode: process.env.NUXT_PUBLIC_GTM_CODE || null,
amplitudeCode: process.env.NUXT_PUBLIC_AMPLITUDE_CODE || null, amplitudeCode: process.env.NUXT_PUBLIC_AMPLITUDE_CODE || null,
crispWebsiteId: process.env.NUXT_PUBLIC_CRISP_WEBSITE_ID || 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, 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 // Config within public will be also exposed to the client
SENTRY_DSN_PUBLIC: process.env.SENTRY_DSN_PUBLIC, SENTRY_DSN_PUBLIC: process.env.SENTRY_DSN_PUBLIC,

View File

@ -21,10 +21,7 @@ export const useAppStore = defineStore("app", {
}, },
}), }),
getters: { getters: {
paidPlansEnabled: () => useRuntimeConfig().public.paidPlansEnabled,
featureBaseEnabled: () => useRuntimeConfig().public.featureBaseOrganization !== null, featureBaseEnabled: () => useRuntimeConfig().public.featureBaseOrganization !== null,
selfHosted: () => useRuntimeConfig().public.selfHosted,
aiFeaturesEnabled: () => useRuntimeConfig().public.aiFeaturesEnabled,
crispEnabled: () => useRuntimeConfig().public.crispWebsiteId !== null && useRuntimeConfig().public.crispWebsiteId !== '', crispEnabled: () => useRuntimeConfig().public.crispWebsiteId !== null && useRuntimeConfig().public.crispWebsiteId !== '',
}, },
actions: { 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 availableIntegrations = computed(() => {
const user = useAuthStore().user const user = useAuthStore().user
const featureFlagsStore = useFeatureFlagsStore()
if (!user) return integrations.value if (!user) return integrations.value
const enrichedIntegrations = new Map() const enrichedIntegrations = new Map()
for (const [key, integration] of integrations.value.entries()) { for (const [key, integration] of integrations.value.entries()) {
if (featureFlagsStore.getFlag(`integrations.${key}`, true)) {
enrichedIntegrations.set(key, { enrichedIntegrations.set(key, {
...integration, ...integration,
id: key, id: key,
requires_subscription: !user.is_subscribed && integration.is_pro, requires_subscription: !user.is_subscribed && integration.is_pro,
}) })
} }
}
return enrichedIntegrations return enrichedIntegrations
}) })