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:
Mir Arif Hasan 2026-05-26 00:47:52 +06:00 committed by GitHub
parent 4405dbf6c3
commit c85687a8c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 378 additions and 19 deletions

View File

@ -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 clients IP address is understood as the left-most entry in the X-Forwarded-For header
TRUST_PROXY=false

View File

@ -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

View File

@ -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, {

View File

@ -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;

View File

@ -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',

View File

@ -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",

View File

@ -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"))
}

View File

@ -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
}

View File

@ -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,

View File

@ -0,0 +1,6 @@
query GetProxyAppUrl {
proxyAppUrl {
value
name
}
}

View File

@ -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")
},
}

View File

@ -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",

View File

@ -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']

View File

@ -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>

View File

@ -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[] = [

View File

@ -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 });
}
});

View File

@ -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,
];

View File

@ -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;