mirror of
https://github.com/hoppscotch/hoppscotch.git
synced 2026-06-04 21:05:33 +08:00
feat: make proxy URL configurable from env and admin dashboard (#6336)
Co-authored-by: nivedin <nivedinp@gmail.com> Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
parent
4405dbf6c3
commit
c85687a8c7
@ -14,6 +14,12 @@ DATA_ENCRYPTION_KEY=data encryption key with 32 char
|
||||
# bundle names like `app://{bundle-name}/`
|
||||
WHITELISTED_ORIGINS=http://localhost:3170,http://localhost:3000,http://localhost:3100,app://localhost_3200,app://hoppscotch
|
||||
|
||||
# Optional: Default proxy URL offered to clients when proxying is enabled.
|
||||
# Clients control whether requests are proxied via the in-app toggle; this only
|
||||
# sets which URL they default to. Remove the variable to configure it solely
|
||||
# from the admin dashboard.
|
||||
PROXY_APP_URL="https://proxy.hoppscotch.io"
|
||||
|
||||
# If true, the client’s IP address is understood as the left-most entry in the X-Forwarded-For header
|
||||
TRUST_PROXY=false
|
||||
|
||||
|
||||
@ -5,12 +5,15 @@ import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { SMTPAuthType } from 'src/mailer/helper';
|
||||
import { decrypt, encrypt } from 'src/utils';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { InfraConfig } from 'src/generated/prisma/client';
|
||||
|
||||
export enum ServiceStatus {
|
||||
ENABLE = 'ENABLE',
|
||||
DISABLE = 'DISABLE',
|
||||
}
|
||||
|
||||
const SYNC_ONLY_VARIABLES = [InfraConfigEnum.PROXY_APP_URL];
|
||||
|
||||
type DefaultInfraConfig = {
|
||||
name: InfraConfigEnum;
|
||||
value: string;
|
||||
@ -344,6 +347,11 @@ export async function getDefaultInfraConfigs(): Promise<DefaultInfraConfig[]> {
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.PROXY_APP_URL,
|
||||
value: process.env.PROXY_APP_URL || 'https://proxy.hoppscotch.io',
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
|
||||
value: false.toString(),
|
||||
@ -413,6 +421,54 @@ export async function getEncryptionRequiredInfraConfigEntries(
|
||||
return requiredEncryption;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the 'infra_config' table with .env file
|
||||
* @returns Array of InfraConfig
|
||||
*/
|
||||
export async function syncInfraConfigWithEnvFile() {
|
||||
const prisma = getSharedPrismaInstance();
|
||||
const dbInfraConfigs = await prisma.infraConfig.findMany({
|
||||
where: { name: { in: SYNC_ONLY_VARIABLES } },
|
||||
});
|
||||
|
||||
const updateRequiredObjs: (Partial<InfraConfig> & { id: string })[] = [];
|
||||
|
||||
for (const dbConfig of dbInfraConfigs) {
|
||||
const envValue = process.env[dbConfig.name];
|
||||
|
||||
// If the env var is unset, leave the admin-set DB value alone. Otherwise
|
||||
// an admin's later override would be wiped on every restart.
|
||||
if (envValue === undefined) continue;
|
||||
|
||||
// lastSyncedEnvFileValue null check for backward compatibility from 2024.10.2 and below
|
||||
if (!dbConfig.lastSyncedEnvFileValue) {
|
||||
const configValue = dbConfig.isEncrypted ? encrypt(envValue) : envValue;
|
||||
updateRequiredObjs.push({
|
||||
id: dbConfig.id,
|
||||
value: dbConfig.value === null ? configValue : undefined,
|
||||
lastSyncedEnvFileValue: configValue,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the value in the database is different from the value in the .env file, means the value in the .env file has been updated
|
||||
const rawLastSyncedEnvFileValue = dbConfig.isEncrypted
|
||||
? decrypt(dbConfig.lastSyncedEnvFileValue)
|
||||
: dbConfig.lastSyncedEnvFileValue;
|
||||
|
||||
if (rawLastSyncedEnvFileValue !== envValue) {
|
||||
const configValue = dbConfig.isEncrypted ? encrypt(envValue) : envValue;
|
||||
updateRequiredObjs.push({
|
||||
id: dbConfig.id,
|
||||
value: configValue,
|
||||
lastSyncedEnvFileValue: configValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return updateRequiredObjs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if 'infra_config' table is loaded with all entries
|
||||
* @returns boolean
|
||||
|
||||
@ -38,6 +38,18 @@ export class InfraConfigResolver {
|
||||
return isEnabled.right;
|
||||
}
|
||||
|
||||
@Query(() => InfraConfig, {
|
||||
description: 'Get the proxy app URL',
|
||||
})
|
||||
// Public by design: the unauthenticated selfhost client needs this URL before login.
|
||||
async proxyAppUrl() {
|
||||
const infraConfig = await this.infraConfigService.get(
|
||||
InfraConfigEnum.PROXY_APP_URL,
|
||||
);
|
||||
if (E.isLeft(infraConfig)) throwErr(infraConfig.left);
|
||||
return infraConfig.right;
|
||||
}
|
||||
|
||||
/* Subscriptions */
|
||||
|
||||
@Subscription(() => String, {
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
getEncryptionRequiredInfraConfigEntries,
|
||||
getMissingInfraConfigEntries,
|
||||
stopApp,
|
||||
syncInfraConfigWithEnvFile,
|
||||
} from './helper';
|
||||
import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
|
||||
import { AuthProvider } from 'src/auth/helper';
|
||||
@ -123,11 +124,30 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy {
|
||||
await Promise.allSettled(dbOperations);
|
||||
}
|
||||
|
||||
// Restart the app if needed
|
||||
// Sync the InfraConfigs with the .env file, if .env file updates later on
|
||||
const envFileChangesRequired = await syncInfraConfigWithEnvFile();
|
||||
if (envFileChangesRequired.length > 0) {
|
||||
const dbOperations = envFileChangesRequired.map((dbConfig) => {
|
||||
const { id, ...dataObj } = dbConfig;
|
||||
return this.prisma.infraConfig.update({
|
||||
where: { id: dbConfig.id },
|
||||
data: dataObj,
|
||||
});
|
||||
});
|
||||
await Promise.allSettled(dbOperations);
|
||||
}
|
||||
|
||||
// Restart the app if needed. Metadata-only sync writes (where `value`
|
||||
// is undefined because only `lastSyncedEnvFileValue` is being persisted)
|
||||
// don't change runtime config, so they shouldn't trigger a restart.
|
||||
const envValueChanged = envFileChangesRequired.some(
|
||||
(c) => c.value !== undefined,
|
||||
);
|
||||
if (
|
||||
propsToInsert.length > 0 ||
|
||||
encryptionRequiredEntries.length > 0 ||
|
||||
Object.keys(derivedEnv).length > 0
|
||||
Object.keys(derivedEnv).length > 0 ||
|
||||
envValueChanged
|
||||
) {
|
||||
stopApp();
|
||||
}
|
||||
@ -794,6 +814,7 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy {
|
||||
case InfraConfigEnum.GOOGLE_CALLBACK_URL:
|
||||
case InfraConfigEnum.GITHUB_CALLBACK_URL:
|
||||
case InfraConfigEnum.MICROSOFT_CALLBACK_URL:
|
||||
case InfraConfigEnum.PROXY_APP_URL:
|
||||
if (!validateUrl(value)) return fail();
|
||||
break;
|
||||
|
||||
|
||||
@ -52,6 +52,8 @@ export enum InfraConfigEnum {
|
||||
|
||||
VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS',
|
||||
|
||||
PROXY_APP_URL = 'PROXY_APP_URL',
|
||||
|
||||
ALLOW_ANALYTICS_COLLECTION = 'ALLOW_ANALYTICS_COLLECTION',
|
||||
ANALYTICS_USER_ID = 'ANALYTICS_USER_ID',
|
||||
IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
|
||||
|
||||
@ -1331,6 +1331,7 @@
|
||||
"profile_photo": "Profile photo",
|
||||
"proxy": "Proxy",
|
||||
"proxy_url": "Proxy URL",
|
||||
"proxy_url_invalid": "Proxy URL must start with http(s):// and contain no spaces",
|
||||
"proxy_use_toggle": "Use the proxy middleware to send requests",
|
||||
"read_the": "Read the",
|
||||
"register_agent": "Register Agent",
|
||||
|
||||
@ -28,6 +28,9 @@
|
||||
@click="resetSettings"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isProxyUrlInvalid" class="text-tiny text-red-500 -mt-2 pb-2">
|
||||
{{ t("settings.proxy_url_invalid") }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -39,6 +42,7 @@ import { useI18n } from "~/composables/i18n"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { useReadonlyStream } from "~/composables/stream"
|
||||
import { platform } from "~/platform"
|
||||
import { isValidProxyUrl } from "~/helpers/proxyUrl"
|
||||
|
||||
import { KernelInterceptorProxyStore } from "~/platform/std/kernel-interceptors/proxy/store"
|
||||
import { ProxyKernelInterceptorService } from "~/platform/std/kernel-interceptors/proxy/index"
|
||||
@ -57,6 +61,12 @@ const proxyInterceptorService = useService(ProxyKernelInterceptorService)
|
||||
// Local editable copy, synced from the reactive store
|
||||
const proxyUrl = ref(store.settings$.value.proxyUrl)
|
||||
|
||||
// Empty is treated as invalid here — the proxy interceptor needs a real
|
||||
// URL to execute requests against; use the Reset button to restore the
|
||||
// platform default. Regex is shared with the store-boundary validator
|
||||
// so what the UI accepts is exactly what the store will persist.
|
||||
const isProxyUrlInvalid = computed(() => !isValidProxyUrl(proxyUrl.value))
|
||||
|
||||
// When the store's settings change (e.g. async init resolves, or external
|
||||
// tab updates via the Store watcher), keep the local input in sync —
|
||||
// but only if the user hasn't actively edited it to something different.
|
||||
@ -98,6 +108,10 @@ const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
|
||||
)
|
||||
|
||||
async function updateProxyUrl() {
|
||||
if (isProxyUrlInvalid.value) {
|
||||
toast.error(t("settings.proxy_url_invalid"))
|
||||
return
|
||||
}
|
||||
await store.updateSettings({ proxyUrl: proxyUrl.value })
|
||||
toast.success(t("state.saved"))
|
||||
}
|
||||
|
||||
@ -4,14 +4,24 @@ import * as E from "fp-ts/Either"
|
||||
// Default proxy URL
|
||||
export const DEFAULT_HOPP_PROXY_URL = "https://proxy.hoppscotch.io/"
|
||||
|
||||
// Get default proxy URL from platform or return default
|
||||
// Mirrors the backend validateUrl regex (packages/hoppscotch-backend/src/utils.ts).
|
||||
// Keep these in sync — the backend rejects PROXY_APP_URL values that don't match.
|
||||
export const PROXY_URL_REGEX = /^(http|https):\/\/[^ "]+$/
|
||||
|
||||
export const isValidProxyUrl = (value: string): boolean =>
|
||||
PROXY_URL_REGEX.test(value)
|
||||
|
||||
// Get default proxy URL from platform or return default.
|
||||
// Validates the server response so a legacy/empty DB row or a bad env-sync
|
||||
// can't seed buildDefaultSettings() with junk that would then bypass the
|
||||
// store-side validation in KernelInterceptorProxyStore.
|
||||
export const getDefaultProxyUrl = async () => {
|
||||
const proxyAppUrl = platform?.infra?.getProxyAppUrl
|
||||
|
||||
if (proxyAppUrl) {
|
||||
const res = await proxyAppUrl()
|
||||
|
||||
if (E.isRight(res)) {
|
||||
if (E.isRight(res) && isValidProxyUrl(res.right.value)) {
|
||||
return res.right.value
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { ref, readonly, toRaw, type Ref, type DeepReadonly } from "vue"
|
||||
import { Service } from "dioc"
|
||||
import { Store } from "~/kernel/store"
|
||||
import { getDefaultProxyUrl, DEFAULT_HOPP_PROXY_URL } from "~/helpers/proxyUrl"
|
||||
import {
|
||||
getDefaultProxyUrl,
|
||||
DEFAULT_HOPP_PROXY_URL,
|
||||
isValidProxyUrl,
|
||||
} from "~/helpers/proxyUrl"
|
||||
import * as E from "fp-ts/Either"
|
||||
|
||||
const STORE_NAMESPACE = "interceptors.proxy.v1"
|
||||
@ -80,13 +84,17 @@ export class KernelInterceptorProxyStore extends Service {
|
||||
watcher.on("change", async ({ value }: { value?: unknown }) => {
|
||||
if (value) {
|
||||
const storedData = value as StoredData
|
||||
const incomingProxyUrl = storedData.settings?.proxyUrl
|
||||
this._settings.value = {
|
||||
...this._settings.value,
|
||||
// Only sync user-configurable fields from external changes.
|
||||
// Fallback to current value if persisted data is missing the field
|
||||
// (e.g. older schema). accessToken stays env-derived.
|
||||
// Keep the current value if the incoming one is missing (older
|
||||
// schema) or invalid (another tab wrote junk via a path that
|
||||
// bypassed validation). accessToken stays env-derived.
|
||||
proxyUrl:
|
||||
storedData.settings?.proxyUrl ?? this._settings.value.proxyUrl,
|
||||
incomingProxyUrl && isValidProxyUrl(incomingProxyUrl)
|
||||
? incomingProxyUrl
|
||||
: this._settings.value.proxyUrl,
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -112,12 +120,25 @@ export class KernelInterceptorProxyStore extends Service {
|
||||
|
||||
if (E.isRight(loadResult) && loadResult.right) {
|
||||
const storedData = loadResult.right
|
||||
// Reject persisted proxyUrl that wouldn't survive backend validation
|
||||
// (e.g. junk left over from before client-side validation existed).
|
||||
// Without this, execute() would post requests to an invalid URL.
|
||||
const persistedProxyUrl = storedData.settings?.proxyUrl
|
||||
const proxyUrl =
|
||||
persistedProxyUrl && isValidProxyUrl(persistedProxyUrl)
|
||||
? persistedProxyUrl
|
||||
: defaults.proxyUrl
|
||||
this._settings.value = {
|
||||
...defaults,
|
||||
// Only restore user-configurable fields from storage.
|
||||
// accessToken is env-derived (VITE_PROXYSCOTCH_ACCESS_TOKEN) and
|
||||
// must always reflect the current deployment, not a stale persisted value.
|
||||
proxyUrl: storedData.settings?.proxyUrl ?? defaults.proxyUrl,
|
||||
proxyUrl,
|
||||
}
|
||||
// If we had to discard a bad persisted value, write the corrected
|
||||
// settings back so we don't repeat the fallback on every boot.
|
||||
if (proxyUrl !== persistedProxyUrl) {
|
||||
await this.persistSettings()
|
||||
}
|
||||
} else {
|
||||
this._settings.value = { ...defaults }
|
||||
@ -153,6 +174,17 @@ export class KernelInterceptorProxyStore extends Service {
|
||||
public async updateSettings(
|
||||
patch: Pick<ProxySettings, "proxyUrl">
|
||||
): Promise<void> {
|
||||
// Belt-and-suspenders: the settings UI already gates this, but the
|
||||
// store is a public API any future caller can hit, and execute() uses
|
||||
// proxyUrl directly. Reject invalid input here so the interceptor
|
||||
// can't be silently broken from outside the settings screen.
|
||||
if (!isValidProxyUrl(patch.proxyUrl)) {
|
||||
console.warn(
|
||||
"[ProxyStore] Refused to persist invalid proxy URL:",
|
||||
patch.proxyUrl
|
||||
)
|
||||
return
|
||||
}
|
||||
this._settings.value = {
|
||||
...this._settings.value,
|
||||
proxyUrl: patch.proxyUrl,
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
query GetProxyAppUrl {
|
||||
proxyAppUrl {
|
||||
value
|
||||
name
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
import { runGQLQuery } from "@hoppscotch/common/helpers/backend/GQLClient"
|
||||
import { InfraPlatformDef } from "@hoppscotch/common/platform/infra"
|
||||
import { GetSmtpStatusDocument } from "@app/api/generated/graphql"
|
||||
import {
|
||||
GetProxyAppUrlDocument,
|
||||
GetSmtpStatusDocument,
|
||||
} from "@app/api/generated/graphql"
|
||||
import * as E from "fp-ts/Either"
|
||||
|
||||
const getSMTPStatus = () => {
|
||||
@ -10,6 +13,13 @@ const getSMTPStatus = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const getProxyAppUrl = () => {
|
||||
return runGQLQuery({
|
||||
query: GetProxyAppUrlDocument,
|
||||
variables: {},
|
||||
})
|
||||
}
|
||||
|
||||
export const InfraPlatform: InfraPlatformDef = {
|
||||
getIsSMTPEnabled: async () => {
|
||||
const res = await getSMTPStatus()
|
||||
@ -20,4 +30,13 @@ export const InfraPlatform: InfraPlatformDef = {
|
||||
|
||||
return E.left("SMTP_STATUS_FETCH_FAILED")
|
||||
},
|
||||
getProxyAppUrl: async () => {
|
||||
const res = await getProxyAppUrl()
|
||||
|
||||
if (E.isRight(res)) {
|
||||
return E.right(res.right.proxyAppUrl)
|
||||
}
|
||||
|
||||
return E.left("PROXY_APP_URL_FETCH_FAILED")
|
||||
},
|
||||
}
|
||||
|
||||
@ -120,6 +120,13 @@
|
||||
"update_failure": "Failed to update rate limit configurations!!",
|
||||
"input_validation_error": "Please enter valid values for rate limit configurations"
|
||||
},
|
||||
"proxy_url_configs": {
|
||||
"description": "Configure proxy URL for the app",
|
||||
"input_validation": "Proxy URL must start with http(s):// and contain no spaces",
|
||||
"title": "Proxy URL Configurations",
|
||||
"update_failure": "Failed to update proxy URL configurations!!",
|
||||
"url_placeholder": "Enter the Proxy URL"
|
||||
},
|
||||
"reset": {
|
||||
"confirm_reset": "Hoppscotch server must restart to reflect the new changes. Confirm the reset of server configurations?",
|
||||
"description": "Default configurations will be loaded as specified in the environment file",
|
||||
@ -137,6 +144,7 @@
|
||||
"auth": "Authentication",
|
||||
"activity": "Activity",
|
||||
"infra_tokens": "Infra Tokens",
|
||||
"proxy": "Proxy",
|
||||
"rate_limit": "Rate Limit",
|
||||
"smtp": "SMTP",
|
||||
"miscellaneous": "Miscellaneous",
|
||||
|
||||
@ -46,9 +46,7 @@ declare module 'vue' {
|
||||
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
|
||||
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
|
||||
IconLucideInfo: typeof import('~icons/lucide/info')['default']
|
||||
IconLucideLock: typeof import('~icons/lucide/lock')['default']
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideShield: typeof import('~icons/lucide/shield')['default']
|
||||
IconLucideUser: typeof import('~icons/lucide/user')['default']
|
||||
OnboardingAuthProviderCard: typeof import('./components/onboarding/AuthProviderCard.vue')['default']
|
||||
OnboardingAuthSetup: typeof import('./components/onboarding/AuthSetup.vue')['default']
|
||||
@ -62,6 +60,7 @@ declare module 'vue' {
|
||||
SettingsHistoryConfiguration: typeof import('./components/settings/HistoryConfiguration.vue')['default']
|
||||
SettingsMockServerConfig: typeof import('./components/settings/MockServerConfig.vue')['default']
|
||||
SettingsOAuthProviderConfigurations: typeof import('./components/settings/OAuthProviderConfigurations.vue')['default']
|
||||
SettingsProxyURLConfiguration: typeof import('./components/settings/ProxyURLConfiguration.vue')['default']
|
||||
SettingsRateLimit: typeof import('./components/settings/RateLimit.vue')['default']
|
||||
SettingsReset: typeof import('./components/settings/Reset.vue')['default']
|
||||
SettingsServerRestart: typeof import('./components/settings/ServerRestart.vue')['default']
|
||||
|
||||
@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="grid md:grid-cols-3 gap-8 md:gap-4 pt-8">
|
||||
<div class="md:col-span-1">
|
||||
<h3 class="heading">{{ t('configs.proxy_url_configs.title') }}</h3>
|
||||
<p class="my-1 text-secondaryLight">
|
||||
{{ t('configs.proxy_url_configs.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:px-8 md:col-span-2">
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t('configs.proxy_url_configs.title') }}
|
||||
</h4>
|
||||
|
||||
<div class="flex items-center space-y-4 py-4">
|
||||
<div class="flex justify-between w-full">
|
||||
<HoppSmartInput
|
||||
v-model="proxyConfigs.fields.proxy_app_url"
|
||||
:placeholder="t('configs.proxy_url_configs.url_placeholder')"
|
||||
:autofocus="false"
|
||||
class="!my-2 !bg-primaryLight w-full max-w-xs"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
blank
|
||||
to="https://docs.hoppscotch.io/documentation/self-host/community-edition"
|
||||
:title="t('support.documentation')"
|
||||
:icon="IconHelpCircle"
|
||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="getFieldError('proxy_app_url')"
|
||||
class="flex items-center justify-between px-2 font-semibold text-red-700 rounded-lg max-w-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<icon-lucide-info class="mr-2" />
|
||||
<span>{{ t('configs.proxy_url_configs.input_validation') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useI18n } from '~/composables/i18n';
|
||||
import {
|
||||
hasInputValidationFailed,
|
||||
isValidProxyUrl,
|
||||
ServerConfigs,
|
||||
} from '~/helpers/configs';
|
||||
import IconHelpCircle from '~icons/lucide/help-circle';
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
config: ServerConfigs;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:config', v: ServerConfigs): void;
|
||||
}>();
|
||||
|
||||
const workingConfigs = useVModel(props, 'config', emit);
|
||||
|
||||
const proxyConfigs = computed({
|
||||
get() {
|
||||
return workingConfigs.value?.proxyUrlConfigs;
|
||||
},
|
||||
set(value) {
|
||||
workingConfigs.value.proxyUrlConfigs = value;
|
||||
},
|
||||
});
|
||||
|
||||
// Input Validation — uses the shared regex helper so the UI accepts exactly
|
||||
// what the backend's validateUrl will (and what the kernel store will persist).
|
||||
// Empty is also flagged here so the inline error banner appears in-context,
|
||||
// matching the app-side Proxy.vue behavior. AreAnyConfigFieldsEmpty still
|
||||
// blocks save for empty, but this gives the user a visible field-level signal.
|
||||
const fieldErrors = computed(() => {
|
||||
const errors: Record<string, boolean> = {};
|
||||
|
||||
const value = proxyConfigs.value?.fields.proxy_app_url ?? '';
|
||||
errors.proxy_app_url = !isValidProxyUrl(value);
|
||||
|
||||
return errors;
|
||||
});
|
||||
|
||||
const getFieldError = (
|
||||
fieldKey: keyof ServerConfigs['proxyUrlConfigs']['fields'],
|
||||
) => fieldErrors.value[fieldKey];
|
||||
|
||||
// `immediate: true` so a pre-existing invalid stored value (e.g. junk left
|
||||
// over from earlier flows) flags the global save guard on mount, not just
|
||||
// after the user re-types the field.
|
||||
watch(
|
||||
fieldErrors,
|
||||
(errors) => {
|
||||
hasInputValidationFailed.value.proxyUrl =
|
||||
Object.values(errors).some(Boolean);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
@ -393,7 +393,7 @@ const fieldErrors = computed(() => {
|
||||
const getFieldError = (fieldKey: StringFieldKey) => fieldErrors.value[fieldKey];
|
||||
|
||||
watch(fieldErrors, (errors) => {
|
||||
hasInputValidationFailed.value = Object.values(errors).some(Boolean);
|
||||
hasInputValidationFailed.value.smtpUrl = Object.values(errors).some(Boolean);
|
||||
});
|
||||
|
||||
const LOGIN_KEYS: StringFieldKey[] = [
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
MAIL_CONFIGS,
|
||||
MICROSOFT_CONFIGS,
|
||||
MOCK_SERVER_CONFIGS,
|
||||
PROXY_URL_CONFIGS,
|
||||
ServerConfigs,
|
||||
TOKEN_VALIDATION_CONFIGS,
|
||||
UpdatedConfigs,
|
||||
@ -212,6 +213,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
|
||||
),
|
||||
},
|
||||
},
|
||||
proxyUrlConfigs: {
|
||||
name: 'proxy_app_url',
|
||||
fields: {
|
||||
proxy_app_url: getFieldValue(InfraConfigEnum.ProxyAppUrl),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Cloning the current configs to working configs
|
||||
@ -286,6 +293,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
|
||||
config.mailConfigs,
|
||||
config.rateLimitConfigs,
|
||||
config.tokenConfigs,
|
||||
config.proxyUrlConfigs,
|
||||
];
|
||||
|
||||
const hasSectionWithEmptyFields = sections.some((section) => {
|
||||
@ -336,6 +344,10 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
|
||||
if (section.name === 'rate_limit')
|
||||
return Object.values(section.fields).some(isNotValidNumber);
|
||||
|
||||
// Proxy URL section has no enabled toggle; ensure it isn't left empty
|
||||
if (section.name === 'proxy_app_url')
|
||||
return Object.values(section.fields).some(isFieldEmpty);
|
||||
|
||||
return (
|
||||
section.enabled && Object.values(section.fields).some(isFieldEmpty)
|
||||
);
|
||||
@ -417,6 +429,11 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
|
||||
enabled: true,
|
||||
fields: updatedConfigs?.mockServerConfigs?.fields ?? {},
|
||||
},
|
||||
{
|
||||
config: PROXY_URL_CONFIGS,
|
||||
enabled: true,
|
||||
fields: updatedConfigs?.proxyUrlConfigs?.fields,
|
||||
},
|
||||
];
|
||||
|
||||
const transformedConfigs: UpdatedConfigs[] = [];
|
||||
@ -429,6 +446,10 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
|
||||
else if (enabled && fields) {
|
||||
const value =
|
||||
typeof fields === 'string' ? fields : String(fields[key]);
|
||||
// BE rejects empty PROXY_APP_URL and would fail the whole batch.
|
||||
// The form-level guard already blocks the save, but skip here too
|
||||
// so a stray empty value can't blackhole unrelated settings.
|
||||
if (name === InfraConfigEnum.ProxyAppUrl && !value.trim()) return;
|
||||
transformedConfigs.push({ name, value });
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import { ref } from 'vue';
|
||||
import { InfraConfigEnum } from './backend/graphql';
|
||||
|
||||
export type InputValidationStatus = {
|
||||
proxyUrl: boolean;
|
||||
smtpUrl: boolean;
|
||||
};
|
||||
|
||||
// Check if any input validation has failed
|
||||
export const hasInputValidationFailed = ref(false);
|
||||
export const hasInputValidationFailed = ref<InputValidationStatus>({
|
||||
proxyUrl: false,
|
||||
smtpUrl: false,
|
||||
});
|
||||
|
||||
export type SsoAuthProviders = 'google' | 'microsoft' | 'github';
|
||||
|
||||
@ -101,6 +109,13 @@ export type ServerConfigs = {
|
||||
mock_server_wildcard_domain: string;
|
||||
};
|
||||
};
|
||||
|
||||
proxyUrlConfigs: {
|
||||
name: string;
|
||||
fields: {
|
||||
proxy_app_url: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type UpdatedConfigs = {
|
||||
@ -325,6 +340,20 @@ export const MOCK_SERVER_CONFIGS: Config[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const PROXY_URL_CONFIGS: Config[] = [
|
||||
{
|
||||
name: InfraConfigEnum.ProxyAppUrl,
|
||||
key: 'proxy_app_url',
|
||||
},
|
||||
];
|
||||
|
||||
// Mirrors the backend validateUrl regex (packages/hoppscotch-backend/src/utils.ts).
|
||||
// Keep these in sync — the backend rejects PROXY_APP_URL values that don't match.
|
||||
export const PROXY_URL_REGEX = /^(http|https):\/\/[^ "]+$/;
|
||||
|
||||
export const isValidProxyUrl = (value: string): boolean =>
|
||||
PROXY_URL_REGEX.test(value);
|
||||
|
||||
export const ALL_CONFIGS = [
|
||||
GOOGLE_CONFIGS,
|
||||
MICROSOFT_CONFIGS,
|
||||
@ -336,4 +365,5 @@ export const ALL_CONFIGS = [
|
||||
RATE_LIMIT_CONFIGS,
|
||||
TOKEN_VALIDATION_CONFIGS,
|
||||
MOCK_SERVER_CONFIGS,
|
||||
PROXY_URL_CONFIGS,
|
||||
];
|
||||
|
||||
@ -31,13 +31,18 @@
|
||||
<HoppSmartTab :id="'token'" :label="t('configs.tabs.infra_tokens')">
|
||||
<Tokens />
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab id="proxy" :label="t('configs.tabs.proxy')">
|
||||
<SettingsProxyURLConfiguration
|
||||
class="pb-8 px-4"
|
||||
v-model:config="workingConfigs"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab :id="'rate-limit'" :label="t('configs.tabs.rate_limit')">
|
||||
<SettingsRateLimit v-model:config="workingConfigs" />
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab id="miscellaneous" :label="t('configs.tabs.miscellaneous')">
|
||||
<div class="pb-8 px-4 flex flex-col space-y-8 divide-y divide-divider">
|
||||
<SettingsDataSharing v-model:config="workingConfigs" />
|
||||
<SettingsMockServer v-model:config="workingConfigs" />
|
||||
<SettingsReset />
|
||||
</div>
|
||||
</HoppSmartTab>
|
||||
@ -83,7 +88,15 @@ const showSaveChangesModal = ref(false);
|
||||
const initiateServerRestart = ref(false);
|
||||
|
||||
// Tabs
|
||||
type OptionTabs = 'auth' | 'smtp' | 'token' | 'miscellaneous' | 'rate-limit';
|
||||
type OptionTabs =
|
||||
| 'auth'
|
||||
| 'smtp'
|
||||
| 'token'
|
||||
| 'proxy'
|
||||
| 'miscellaneous'
|
||||
| 'rate-limit'
|
||||
| 'mock';
|
||||
|
||||
const selectedOptionTab = ref<OptionTabs>('auth');
|
||||
|
||||
// Obtain the current and working configs from the useConfigHandler composable
|
||||
@ -102,12 +115,12 @@ const {
|
||||
const isConfigUpdated = computed(() =>
|
||||
currentConfigs.value && workingConfigs.value
|
||||
? !isEqual(currentConfigs.value, workingConfigs.value)
|
||||
: false
|
||||
: false,
|
||||
);
|
||||
|
||||
// Check if any of the fields in workingConfigs are empty
|
||||
const areAnyFieldsEmpty = computed(() =>
|
||||
workingConfigs.value ? AreAnyConfigFieldsEmpty(workingConfigs.value) : false
|
||||
workingConfigs.value ? AreAnyConfigFieldsEmpty(workingConfigs.value) : false,
|
||||
);
|
||||
|
||||
const triggerSaveChangesModal = () => {
|
||||
@ -119,7 +132,8 @@ const triggerSaveChangesModal = () => {
|
||||
return toast.error(t('configs.mail_configs.smtp_auth_incomplete'));
|
||||
}
|
||||
|
||||
if (hasInputValidationFailed.value) {
|
||||
// Check if any of the input validations have failed
|
||||
if (Object.values(hasInputValidationFailed.value).some(Boolean)) {
|
||||
return toast.error(t('configs.input_validation_error'));
|
||||
}
|
||||
showSaveChangesModal.value = true;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user