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:
parent
1dffd27390
commit
79d3dd7888
|
|
@ -4,6 +4,8 @@ APP_KEY=
|
|||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
SELF_HOSTED=true
|
||||
|
||||
LOG_CHANNEL=errorlog
|
||||
LOG_LEVEL=debug
|
||||
|
||||
|
|
@ -43,5 +45,4 @@ JWT_SECRET=
|
|||
MUX_WORKSPACE_ID=
|
||||
MUX_API_TOKEN=
|
||||
|
||||
OPEN_AI_API_KEY=
|
||||
SELF_HOSTED=true
|
||||
OPEN_AI_API_KEY=
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
});
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
@ -23,7 +24,7 @@ class TemplateController extends Controller
|
|||
} else {
|
||||
$query->where(function ($q) {
|
||||
$q->where('publicly_listed', true)
|
||||
->orWhere('creator_id', Auth::id());
|
||||
->orWhere('creator_id', Auth::id());
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -74,4 +74,8 @@ return [
|
|||
'fonts_api_key' => env('GOOGLE_FONTS_API_KEY'),
|
||||
],
|
||||
|
||||
'zapier' => [
|
||||
'enabled' => env('ZAPIER_ENABLED', false),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
@ -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
|
||||
NUXT_PUBLIC_ENV=dev
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,27 +1,37 @@
|
|||
<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
|
||||
apologize for
|
||||
the
|
||||
inconvenience.</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<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)">
|
||||
Report this error
|
||||
</UButton>
|
||||
</div>
|
||||
<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
|
||||
apologize for
|
||||
the
|
||||
inconvenience.
|
||||
</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<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)"
|
||||
>
|
||||
Report this error
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<slot />
|
||||
</ErrorBoundary>
|
||||
</template>
|
||||
<slot />
|
||||
</ErrorBoundary>
|
||||
</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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -53,23 +53,25 @@
|
|||
label="Form Theme"
|
||||
/>
|
||||
|
||||
<label class="text-gray-700 font-medium text-sm">Font Style</label>
|
||||
<v-button
|
||||
color="white"
|
||||
class="w-full mb-4"
|
||||
size="small"
|
||||
@click="showGoogleFontPicker = true"
|
||||
>
|
||||
<span :style="{ 'font-family': (form.font_family?form.font_family+' !important':null) }">
|
||||
{{ form.font_family || 'Default' }}
|
||||
</span>
|
||||
</v-button>
|
||||
<GoogleFontPicker
|
||||
:show="showGoogleFontPicker"
|
||||
:font="form.font_family || null"
|
||||
@close="showGoogleFontPicker=false"
|
||||
@apply="onApplyFont"
|
||||
/>
|
||||
<template v-if="useFeatureFlag('services.google.fonts')">
|
||||
<label class="text-gray-700 font-medium text-sm">Font Style</label>
|
||||
<v-button
|
||||
color="white"
|
||||
class="w-full mb-4"
|
||||
size="small"
|
||||
@click="showGoogleFontPicker = true"
|
||||
>
|
||||
<span :style="{ 'font-family': (form.font_family?form.font_family+' !important':null) }">
|
||||
{{ form.font_family || 'Default' }}
|
||||
</span>
|
||||
</v-button>
|
||||
<GoogleFontPicker
|
||||
:show="showGoogleFontPicker"
|
||||
:font="form.font_family || null"
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,21 +21,21 @@
|
|||
</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">
|
||||
<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"
|
||||
>
|
||||
Privacy Policy
|
||||
</router-link>
|
||||
<template v-if="!useFeatureFlag('self_hosted')">
|
||||
<router-link
|
||||
:to="{ name: 'privacy-policy' }"
|
||||
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
|
||||
>
|
||||
Privacy Policy
|
||||
</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>
|
||||
<router-link
|
||||
: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"
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -87,19 +87,21 @@
|
|||
Create account
|
||||
</v-button>
|
||||
|
||||
<p class="text-gray-600/50 text-sm text-center my-4">
|
||||
Or
|
||||
</p>
|
||||
<v-button
|
||||
native-type="buttom"
|
||||
color="white"
|
||||
class="space-x-4 flex items-center w-full"
|
||||
:loading="false"
|
||||
@click.prevent="signInwithGoogle"
|
||||
>
|
||||
<Icon name="devicon:google" />
|
||||
<span class="mx-2">Sign in with Google</span>
|
||||
</v-button>
|
||||
<template v-if="useFeatureFlag('services.google.auth')">
|
||||
<p class="text-gray-600/50 text-sm text-center my-4">
|
||||
Or
|
||||
</p>
|
||||
<v-button
|
||||
native-type="buttom"
|
||||
color="white"
|
||||
class="space-x-4 flex items-center w-full"
|
||||
:loading="false"
|
||||
@click.prevent="signInwithGoogle"
|
||||
>
|
||||
<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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
export function useFeatureFlag(flagName, defaultValue = null) {
|
||||
const featureStore = useFeatureFlagsStore()
|
||||
return featureStore.getFlag(flagName, defaultValue)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
@ -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" })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -10,15 +10,18 @@ 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()) {
|
||||
enrichedIntegrations.set(key, {
|
||||
...integration,
|
||||
id: key,
|
||||
requires_subscription: !user.is_subscribed && integration.is_pro,
|
||||
})
|
||||
if (featureFlagsStore.getFlag(`integrations.${key}`, true)) {
|
||||
enrichedIntegrations.set(key, {
|
||||
...integration,
|
||||
id: key,
|
||||
requires_subscription: !user.is_subscribed && integration.is_pro,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return enrichedIntegrations
|
||||
|
|
|
|||
Loading…
Reference in New Issue