feat: secret variables in environments (#3779)

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
Nivedin
2024-02-08 21:58:42 +05:30
committed by GitHub
parent 16803acb26
commit 00862eb192
55 changed files with 2141 additions and 439 deletions

View File

@@ -24,6 +24,7 @@
"go_back": "Go back",
"go_forward": "Go forward",
"group_by": "Group by",
"hide_secret": "Hide secret",
"label": "Label",
"learn_more": "Learn more",
"less": "Less",
@@ -43,6 +44,7 @@
"search": "Search",
"send": "Send",
"share": "Share",
"show_secret": "Show secret",
"start": "Start",
"starting": "Starting",
"stop": "Stop",
@@ -238,6 +240,7 @@
"profile": "Login to view your profile",
"protocols": "Protocols are empty",
"schema": "Connect to a GraphQL endpoint to view schema",
"secret_environments": "Secret environments are not synced to Hoppscotch hence user has to provide it's value during run-time.",
"shared_requests": "Shared requests are empty",
"shared_requests_logout": "Login to view your shared requests or create a new one",
"subscription": "Subscriptions are empty",
@@ -269,6 +272,8 @@
"quick_peek": "Environment Quick Peek",
"replace_with_variable": "Replace with variable",
"scope": "Scope",
"secret": "Secret",
"secret_value": "Secret value",
"select": "Select environment",
"set": "Set environment",
"set_as_environment": "Set as environment",
@@ -277,6 +282,7 @@
"updated": "Environment updated",
"value": "Value",
"variable": "Variable",
"variables":"Variables",
"variable_list": "Variable List"
},
"error": {
@@ -413,6 +419,8 @@
"description": "Inspect possible errors",
"environment": {
"add_environment": "Add to Environment",
"add_environment_value": "Add value",
"empty_value": "Environment value is empty for the variable '{variable}' ",
"not_found": "Environment variable “{environment}” not found."
},
"header": {

View File

@@ -21,7 +21,7 @@
<label for="value" class="min-w-[2.5rem] font-semibold">{{
t("environment.value")
}}</label>
<input
<SmartEnvInput
v-model="editingValue"
type="text"
class="input"
@@ -154,12 +154,14 @@ const addEnvironment = async () => {
addGlobalEnvVariable({
key: editingName.value,
value: editingValue.value,
secret: false,
})
toast.success(`${t("environment.updated")}`)
} else if (scope.value.type === "my-environment") {
addEnvironmentVariable(scope.value.index, {
key: editingName.value,
value: editingValue.value,
secret: false,
})
toast.success(`${t("environment.updated")}`)
} else {

View File

@@ -9,7 +9,7 @@
</template>
<script setup lang="ts">
import { Environment } from "@hoppscotch/data"
import { Environment, NonSecretEnvironment } from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import { ref } from "vue"
@@ -340,13 +340,13 @@ const showImportFailedError = () => {
const handleImportToStore = async (
environments: Environment[],
globalEnv?: Environment
globalEnv?: NonSecretEnvironment
) => {
// if there's a global env, add them to the store
if (globalEnv) {
globalEnv.variables.forEach(({ key, value }) => {
addGlobalEnvVariable({ key, value })
})
globalEnv.variables.forEach(({ key, value, secret }) =>
addGlobalEnvVariable({ key, value, secret })
)
}
if (props.environmentType === "MY_ENV") {

View File

@@ -210,7 +210,10 @@
{{ variable.key }}
</span>
<span class="min-w-[9rem] w-full truncate text-secondaryLight">
{{ variable.value }}
<template v-if="variable.secret"> ******** </template>
<template v-else>
{{ variable.value }}
</template>
</span>
</div>
<div v-if="globalEnvs.length === 0" class="text-secondaryLight">
@@ -265,7 +268,10 @@
{{ variable.key }}
</span>
<span class="min-w-[9rem] w-full truncate text-secondaryLight">
{{ variable.value }}
<template v-if="variable.secret"> ******** </template>
<template v-else>
{{ variable.value }}
</template>
</span>
</div>
<div
@@ -479,15 +485,20 @@ const selectedEnv = computed(() => {
type: "MY_ENV",
index: props.modelValue.index,
name: props.modelValue.environment?.name,
variables: props.modelValue.environment?.variables,
}
} else if (props.modelValue?.type === "team-environment") {
return {
type: "TEAM_ENV",
name: props.modelValue.environment.environment.name,
teamEnvID: props.modelValue.environment.id,
variables: props.modelValue.environment.environment.variables,
}
}
return { type: "global", name: "Global" }
return {
type: "global",
name: "Global",
}
}
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
const environment =
@@ -582,9 +593,7 @@ const environmentVariables = computed(() => {
})
const editGlobalEnv = () => {
invokeAction("modals.my.environment.edit", {
envName: "Global",
})
invokeAction("modals.global.environment.update", {})
}
const editEnv = () => {

View File

@@ -24,6 +24,8 @@
:action="action"
:editing-environment-index="editingEnvironmentIndex"
:editing-variable-name="editingVariableName"
:env-vars="envVars"
:is-secret-option-selected="secretOptionSelected"
@hide-modal="displayModalEdit(false)"
/>
<EnvironmentsAdd
@@ -37,7 +39,7 @@
<HoppSmartConfirmModal
:show="showConfirmRemoveEnvModal"
:title="t('confirm.remove_team')"
:title="`${t('confirm.remove_environment')}`"
@hide-modal="showConfirmRemoveEnvModal = false"
@resolve="removeSelectedEnvironment()"
/>
@@ -67,6 +69,7 @@ import { deleteTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironme
import { useToast } from "~/composables/toast"
import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue"
import { Environment } from "@hoppscotch/data"
const t = useI18n()
const toast = useToast()
@@ -88,6 +91,8 @@ const environmentType = ref<EnvironmentsChooseType>({
const globalEnv = useReadonlyStream(globalEnv$, [])
const globalEnvironment = computed(() => ({
v: 1 as const,
id: "Global",
name: "Global",
variables: globalEnv.value,
}))
@@ -186,6 +191,7 @@ const action = ref<"new" | "edit">("edit")
const editingEnvironmentIndex = ref<"Global" | null>(null)
const editingVariableName = ref("")
const editingVariableValue = ref("")
const secretOptionSelected = ref(false)
const position = ref({ top: 0, left: 0 })
@@ -203,6 +209,7 @@ const displayModalEdit = (shouldDisplay: boolean) => {
const editEnvironment = (environmentIndex: "Global") => {
editingEnvironmentIndex.value = environmentIndex
action.value = "edit"
editingVariableName.value = ""
displayModalEdit(true)
}
@@ -232,6 +239,9 @@ const removeSelectedEnvironment = () => {
const resetSelectedData = () => {
editingEnvironmentIndex.value = null
editingVariableName.value = ""
editingVariableValue.value = ""
secretOptionSelected.value = false
}
defineActionHandler("modals.environment.new", () => {
@@ -243,11 +253,19 @@ defineActionHandler("modals.environment.delete-selected", () => {
showConfirmRemoveEnvModal.value = true
})
const additionalVars = ref<Environment["variables"]>([])
const envVars = () => [...globalEnv.value, ...additionalVars.value]
defineActionHandler(
"modals.my.environment.edit",
({ envName, variableName }) => {
if (variableName) editingVariableName.value = variableName
envName === "Global" && editEnvironment("Global")
"modals.global.environment.update",
({ variables, isSecret }) => {
if (variables) {
additionalVars.value = variables
}
secretOptionSelected.value = isSecret ?? false
editEnvironment("Global")
editingVariableName.value = "Global"
}
)

View File

@@ -16,76 +16,103 @@
@submit="saveEnvironment"
/>
<div class="flex flex-1 items-center justify-between">
<label for="variableList" class="p-4">
{{ t("environment.variable_list") }}
</label>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="clearIcon"
@click="clearContent()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlus"
:title="t('add.new')"
@click="addEnvironmentVariable"
/>
</div>
</div>
<div
v-if="evnExpandError"
class="mb-2 w-full overflow-auto whitespace-normal rounded bg-primaryLight px-4 py-2 font-mono text-red-400"
>
{{ t("environment.nested_overflow") }}
</div>
<div class="divide-y divide-dividerLight rounded border border-divider">
<div class="my-4 flex flex-col border border-divider rounded">
<div
v-for="({ id, env }, index) in vars"
:key="`variable-${id}-${index}`"
class="flex divide-x divide-dividerLight"
v-if="evnExpandError"
class="mb-2 w-full overflow-auto whitespace-normal rounded bg-primaryLight px-4 py-2 font-mono text-red-400"
>
<input
v-model="env.key"
v-focus
class="flex flex-1 bg-transparent px-4 py-2"
:placeholder="`${t('count.variable', { count: index + 1 })}`"
:name="'param' + index"
/>
<SmartEnvInput
v-model="env.value"
:select-text-on-mount="env.key === editingVariableName"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:envs="liveEnvs"
:name="'value' + index"
/>
<div class="flex">
<HoppButtonSecondary
id="variable"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="removeEnvironmentVariable(index)"
/>
</div>
{{ t("environment.nested_overflow") }}
</div>
<HoppSmartPlaceholder
v-if="vars.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<template #body>
<HoppButtonSecondary
:label="`${t('add.new')}`"
filled
@click="addEnvironmentVariable"
/>
<HoppSmartTabs v-model="selectedEnvOption" render-inactive-tabs>
<template #actions>
<div class="flex flex-1 items-center justify-between">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/environments"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="clearIcon"
@click="clearContent()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlus"
:title="t('add.new')"
@click="addEnvironmentVariable"
/>
</div>
</template>
</HoppSmartPlaceholder>
<HoppSmartTab
v-for="tab in tabsData"
:id="tab.id"
:key="tab.id"
:label="tab.label"
>
<div
class="divide-y divide-dividerLight rounded border border-divider"
>
<HoppSmartPlaceholder
v-if="tab.variables.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="tab.emptyStateLabel"
:text="tab.emptyStateLabel"
>
<template #body>
<HoppButtonSecondary
:label="`${t('add.new')}`"
filled
:icon="IconPlus"
@click="addEnvironmentVariable"
/>
</template>
</HoppSmartPlaceholder>
<template v-else>
<div
v-for="({ id, env }, index) in tab.variables"
:key="`variable-${id}-${index}`"
class="flex divide-x divide-dividerLight"
>
<input
v-model="env.key"
v-focus
class="flex flex-1 bg-transparent px-4 py-2"
:placeholder="`${t('count.variable', {
count: index + 1,
})}`"
:name="'param' + index"
/>
<SmartEnvInput
v-model="env.value"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:envs="liveEnvs"
:name="'value' + index"
:secret="tab.isSecret"
:select-text-on-mount="
env.key ? env.key === editingVariableName : false
"
/>
<div class="flex">
<HoppButtonSecondary
id="variable"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="removeEnvironmentVariable(id)"
/>
</div>
</div>
</template>
</div>
</HoppSmartTab>
</HoppSmartTabs>
</div>
</div>
</template>
@@ -112,8 +139,8 @@ import IconTrash2 from "~icons/lucide/trash-2"
import IconDone from "~icons/lucide/check"
import IconPlus from "~icons/lucide/plus"
import IconTrash from "~icons/lucide/trash"
import { clone } from "lodash-es"
import { computed, ref, watch } from "vue"
import IconHelpCircle from "~icons/lucide/help-circle"
import { ComputedRef, computed, ref, watch } from "vue"
import * as E from "fp-ts/Either"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
@@ -136,12 +163,16 @@ import { useReadonlyStream } from "@composables/stream"
import { useColorMode } from "@composables/theming"
import { environmentsStore } from "~/newstore/environments"
import { platform } from "~/platform"
import { useService } from "dioc/vue"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
import { uniqueId } from "lodash-es"
type EnvironmentVariable = {
id: number
env: {
key: string
value: string
key: string
secret: boolean
}
}
@@ -155,6 +186,7 @@ const props = withDefaults(
action: "edit" | "new"
editingEnvironmentIndex?: number | "Global" | null
editingVariableName?: string | null
isSecretOptionSelected?: boolean
envVars?: () => Environment["variables"]
}>(),
{
@@ -162,6 +194,7 @@ const props = withDefaults(
action: "edit",
editingEnvironmentIndex: null,
editingVariableName: null,
isSecretOptionSelected: false,
envVars: () => [],
}
)
@@ -172,11 +205,55 @@ const emit = defineEmits<{
const idTicker = ref(0)
const tabsData: ComputedRef<
{
id: string
label: string
emptyStateLabel: string
isSecret: boolean
variables: EnvironmentVariable[]
}[]
> = computed(() => {
return [
{
id: "variables",
label: t("environment.variables"),
emptyStateLabel: t("empty.environments"),
isSecret: false,
variables: nonSecretVars.value,
},
{
id: "secret",
label: t("environment.secret"),
emptyStateLabel: t("empty.secret_environments"),
isSecret: true,
variables: secretVars.value,
},
]
})
const editingName = ref<string | null>(null)
const editingID = ref<string>("")
const vars = ref<EnvironmentVariable[]>([
{ id: idTicker.value++, env: { key: "", value: "" } },
{ id: idTicker.value++, env: { key: "", value: "", secret: false } },
])
const secretEnvironmentService = useService(SecretEnvironmentService)
const secretVars = computed(() =>
pipe(
vars.value,
A.filter((e) => e.env.secret)
)
)
const nonSecretVars = computed(() =>
pipe(
vars.value,
A.filter((e) => !e.env.secret)
)
)
const clearIcon = refAutoReset<typeof IconTrash2 | typeof IconDone>(
IconTrash2,
1000
@@ -184,14 +261,23 @@ const clearIcon = refAutoReset<typeof IconTrash2 | typeof IconDone>(
const globalVars = useReadonlyStream(globalEnv$, [])
type SelectedEnv = "variables" | "secret"
const selectedEnvOption = ref<SelectedEnv>("variables")
const workingEnv = computed(() => {
if (props.editingEnvironmentIndex === "Global") {
const vars =
props.editingVariableName === "Global"
? props.envVars()
: getGlobalVariables()
return {
name: "Global",
variables: getGlobalVariables(),
variables: vars,
} as Environment
} else if (props.action === "new") {
return {
id: uniqueId(),
name: "",
variables: props.envVars(),
}
@@ -214,6 +300,7 @@ const evnExpandError = computed(() => {
return pipe(
variables,
A.filter(({ secret }) => !secret),
A.exists(({ value }) => E.isLeft(parseTemplateStringE(value, variables)))
)
})
@@ -239,11 +326,29 @@ watch(
(show) => {
if (show) {
editingName.value = workingEnv.value?.name ?? null
selectedEnvOption.value = props.isSecretOptionSelected
? "secret"
: "variables"
if (props.editingEnvironmentIndex !== "Global") {
editingID.value = workingEnv.value?.id ?? uniqueId()
}
vars.value = pipe(
workingEnv.value?.variables ?? [],
A.map((e) => ({
A.mapWithIndex((index, e) => ({
id: idTicker.value++,
env: clone(e),
env: {
key: e.key,
value: e.secret
? secretEnvironmentService.getSecretEnvironmentVariable(
props.editingEnvironmentIndex === "Global"
? "Global"
: workingEnv.value?.id,
index
)?.value ?? ""
: e.value,
secret: e.secret,
},
}))
)
}
@@ -251,7 +356,10 @@ watch(
)
const clearContent = () => {
vars.value = []
vars.value = vars.value.filter((e) =>
selectedEnvOption.value === "secret" ? !e.env.secret : e.env.secret
)
clearIcon.value = IconDone
toast.success(`${t("state.cleared")}`)
}
@@ -262,12 +370,16 @@ const addEnvironmentVariable = () => {
env: {
key: "",
value: "",
secret: selectedEnvOption.value === "secret",
},
})
}
const removeEnvironmentVariable = (index: number) => {
vars.value.splice(index, 1)
const removeEnvironmentVariable = (id: number) => {
const index = vars.value.findIndex((e) => e.id === id)
if (index !== -1) {
vars.value.splice(index, 1)
}
}
const saveEnvironment = () => {
@@ -276,7 +388,7 @@ const saveEnvironment = () => {
return
}
const filterdVariables = pipe(
const filteredVariables = pipe(
vars.value,
A.filterMap(
flow(
@@ -286,14 +398,43 @@ const saveEnvironment = () => {
)
)
const secretVariables = pipe(
filteredVariables,
A.filterMapWithIndex((i, e) =>
e.secret ? O.some({ key: e.key, value: e.value, varIndex: i }) : O.none
)
)
if (editingID.value) {
secretEnvironmentService.addSecretEnvironment(
editingID.value,
secretVariables
)
} else if (props.editingEnvironmentIndex === "Global") {
secretEnvironmentService.addSecretEnvironment("Global", secretVariables)
}
const variables = pipe(
filteredVariables,
A.map((e) =>
e.secret ? { key: e.key, secret: e.secret, value: undefined } : e
)
)
const environmentUpdated: Environment = {
v: 1,
id: uniqueId(),
name: editingName.value,
variables: filterdVariables,
variables,
}
if (props.action === "new") {
// Creating a new environment
createEnvironment(editingName.value, environmentUpdated.variables)
createEnvironment(
editingName.value,
environmentUpdated.variables,
editingID.value
)
setSelectedEnvironmentIndex({
type: "MY_ENV",
index: envList.value.length - 1,
@@ -332,6 +473,7 @@ const saveEnvironment = () => {
const hideModal = () => {
editingName.value = null
selectedEnvOption.value = "variables"
emit("hide-modal")
}
</script>

View File

@@ -135,6 +135,8 @@ import { useToast } from "@composables/toast"
import { TippyComponent } from "vue-tippy"
import { HoppSmartItem } from "@hoppscotch/ui"
import { exportAsJSON } from "~/helpers/import-export/export/environment"
import { useService } from "dioc/vue"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
const t = useI18n()
const toast = useToast()
@@ -150,6 +152,8 @@ const emit = defineEmits<{
const confirmRemove = ref(false)
const secretEnvironmentService = useService(SecretEnvironmentService)
const exportEnvironmentAsJSON = () => {
const { environment, environmentIndex } = props
exportAsJSON(environment, environmentIndex)
@@ -168,6 +172,7 @@ const removeEnvironment = () => {
if (props.environmentIndex === null) return
if (props.environmentIndex !== "Global") {
deleteEnvironment(props.environmentIndex, props.environment.id)
secretEnvironmentService.deleteSecretEnvironment(props.environment.id)
}
toast.success(`${t("state.deleted")}`)
}

View File

@@ -67,6 +67,7 @@
:action="action"
:editing-environment-index="editingEnvironmentIndex"
:editing-variable-name="editingVariableName"
:is-secret-option-selected="secretOptionSelected"
@hide-modal="displayModalEdit(false)"
/>
<EnvironmentsImportExport
@@ -99,6 +100,7 @@ const showModalDetails = ref(false)
const action = ref<"new" | "edit">("edit")
const editingEnvironmentIndex = ref<number | null>(null)
const editingVariableName = ref("")
const secretOptionSelected = ref(false)
const displayModalAdd = (shouldDisplay: boolean) => {
action.value = "new"
@@ -120,18 +122,23 @@ const editEnvironment = (environmentIndex: number) => {
}
const resetSelectedData = () => {
editingEnvironmentIndex.value = null
editingVariableName.value = ""
secretOptionSelected.value = false
}
defineActionHandler(
"modals.my.environment.edit",
({ envName, variableName }) => {
({ envName, variableName, isSecret }) => {
if (variableName) editingVariableName.value = variableName
const envIndex: number = environments.value.findIndex(
(environment: Environment) => {
return environment.name === envName
}
)
if (envName !== "Global") editEnvironment(envIndex)
if (envName !== "Global") {
editEnvironment(envIndex)
secretOptionSelected.value = isSecret ?? false
}
}
)
</script>

View File

@@ -16,90 +16,112 @@
@submit="saveEnvironment"
/>
<div class="flex flex-1 items-center justify-between">
<label for="variableList" class="p-4">
{{ t("environment.variable_list") }}
</label>
<div v-if="!isViewer" class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="clearIcon"
@click="clearContent()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlus"
:title="t('add.new')"
@click="addEnvironmentVariable"
/>
</div>
</div>
<div
v-if="evnExpandError"
class="mb-2 w-full overflow-auto whitespace-normal rounded bg-primaryLight px-4 py-2 font-mono text-red-400"
>
{{ t("environment.nested_overflow") }}
</div>
<div class="divide-y divide-dividerLight rounded border border-divider">
<div class="my-4 flex flex-col border border-divider rounded">
<div
v-for="({ id, env }, index) in vars"
:key="`variable-${id}-${index}`"
class="flex divide-x divide-dividerLight"
v-if="evnExpandError"
class="mb-2 w-full overflow-auto whitespace-normal rounded bg-primaryLight px-4 py-2 font-mono text-red-400"
>
<input
v-model="env.key"
v-focus
class="flex flex-1 bg-transparent px-4 py-2"
:class="isViewer && 'opacity-25'"
:placeholder="`${t('count.variable', { count: index + 1 })}`"
:name="'param' + index"
:disabled="isViewer"
/>
<SmartEnvInput
v-model="env.value"
:select-text-on-mount="env.key === editingVariableName"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:envs="liveEnvs"
:name="'value' + index"
:readonly="isViewer"
/>
<div v-if="!isViewer" class="flex">
<HoppButtonSecondary
id="variable"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="removeEnvironmentVariable(index)"
/>
</div>
{{ t("environment.nested_overflow") }}
</div>
<HoppSmartPlaceholder
v-if="vars.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<template #body>
<HoppButtonSecondary
v-if="isViewer"
disabled
:label="`${t('add.new')}`"
filled
/>
<HoppButtonSecondary
v-else
:label="`${t('add.new')}`"
filled
@click="addEnvironmentVariable"
/>
<HoppSmartTabs v-model="selectedEnvOption" render-inactive-tabs>
<template #actions>
<div class="flex flex-1 items-center justify-between">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/environments"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-if="!isViewer"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="clearIcon"
@click="clearContent()"
/>
<HoppButtonSecondary
v-if="!isViewer"
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlus"
:title="t('add.new')"
@click="addEnvironmentVariable"
/>
</div>
</template>
</HoppSmartPlaceholder>
<HoppSmartTab
v-for="tab in tabsData"
:id="tab.id"
:key="tab.id"
:label="tab.label"
>
<div
class="divide-y divide-dividerLight rounded border border-divider"
>
<HoppSmartPlaceholder
v-if="tab.variables.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="tab.emptyStateLabel"
:text="tab.emptyStateLabel"
>
<template #body>
<HoppButtonSecondary
v-if="!isViewer"
:label="`${t('add.new')}`"
filled
:icon="IconPlus"
@click="addEnvironmentVariable"
/>
</template>
</HoppSmartPlaceholder>
<template v-else>
<div
v-for="({ id, env }, index) in tab.variables"
:key="`variable-${id}-${index}`"
class="flex divide-x divide-dividerLight"
>
<input
v-model="env.key"
v-focus
class="flex flex-1 bg-transparent px-4 py-2"
:placeholder="`${t('count.variable', {
count: index + 1,
})}`"
:name="'param' + index"
:disabled="isViewer"
/>
<SmartEnvInput
v-model="env.value"
:select-text-on-mount="
env.key ? env.key === editingVariableName : false
"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:envs="liveEnvs"
:name="'value' + index"
:secret="tab.isSecret"
:readonly="isViewer && !tab.isSecret"
/>
<div v-if="!isViewer" class="flex">
<HoppButtonSecondary
id="variable"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="removeEnvironmentVariable(id)"
/>
</div>
</div>
</template>
</div>
</HoppSmartTab>
</HoppSmartTabs>
</div>
</div>
</template>
<template v-if="!isViewer" #footer>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
:label="`${t('action.save')}`"
@@ -119,7 +141,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { ComputedRef, computed, ref, watch } from "vue"
import * as E from "fp-ts/Either"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
@@ -141,13 +163,17 @@ import IconTrash from "~icons/lucide/trash"
import IconTrash2 from "~icons/lucide/trash-2"
import IconDone from "~icons/lucide/check"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import { platform } from "~/platform"
import { useService } from "dioc/vue"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
type EnvironmentVariable = {
id: number
env: {
key: string
value: string
secret: boolean
}
}
@@ -163,6 +189,7 @@ const props = withDefaults(
editingTeamId: string | undefined
editingVariableName?: string | null
isViewer?: boolean
isSecretOptionSelected?: boolean
envVars?: () => Environment["variables"]
}>(),
{
@@ -172,6 +199,7 @@ const props = withDefaults(
editingTeamId: "",
editingVariableName: null,
isViewer: false,
isSecretOptionSelected: false,
envVars: () => [],
}
)
@@ -182,11 +210,59 @@ const emit = defineEmits<{
const idTicker = ref(0)
const tabsData: ComputedRef<
{
id: string
label: string
emptyStateLabel: string
isSecret: boolean
variables: EnvironmentVariable[]
}[]
> = computed(() => {
return [
{
id: "variables",
label: t("environment.variables"),
emptyStateLabel: t("empty.environments"),
isSecret: false,
variables: nonSecretVars.value,
},
{
id: "secret",
label: t("environment.secret"),
emptyStateLabel: t("empty.secret_environments"),
isSecret: true,
variables: secretVars.value,
},
]
})
const editingName = ref<string | null>(null)
const editingID = ref<string | null>(null)
const vars = ref<EnvironmentVariable[]>([
{ id: idTicker.value++, env: { key: "", value: "" } },
{ id: idTicker.value++, env: { key: "", value: "", secret: false } },
])
const secretEnvironmentService = useService(SecretEnvironmentService)
const secretVars = computed(() =>
pipe(
vars.value,
A.filter((e) => e.env.secret)
)
)
const nonSecretVars = computed(() =>
pipe(
vars.value,
A.filter((e) => !e.env.secret)
)
)
type SelectedEnv = "variables" | "secret"
const selectedEnvOption = ref<SelectedEnv>("variables")
const clearIcon = refAutoReset<typeof IconTrash2 | typeof IconDone>(
IconTrash2,
1000
@@ -215,22 +291,34 @@ watch(
() => props.show,
(show) => {
if (show) {
editingName.value = props.editingEnvironment?.environment.name ?? null
selectedEnvOption.value = props.isSecretOptionSelected
? "secret"
: "variables"
if (props.action === "new") {
editingName.value = null
vars.value = pipe(
props.envVars() ?? [],
A.map((e: { key: string; value: string }) => ({
A.map((e) => ({
id: idTicker.value++,
env: clone(e),
}))
)
} else if (props.editingEnvironment !== null) {
editingName.value = props.editingEnvironment.environment.name ?? null
editingID.value = props.editingEnvironment.id
vars.value = pipe(
props.editingEnvironment.environment.variables ?? [],
A.map((e: { key: string; value: string }) => ({
A.mapWithIndex((index, e) => ({
id: idTicker.value++,
env: clone(e),
env: {
key: e.key,
value: e.secret
? secretEnvironmentService.getSecretEnvironmentVariable(
editingID.value ?? "",
index
)?.value ?? ""
: e.value,
secret: e.secret,
},
}))
)
}
@@ -250,12 +338,16 @@ const addEnvironmentVariable = () => {
env: {
key: "",
value: "",
secret: selectedEnvOption.value === "secret",
},
})
}
const removeEnvironmentVariable = (index: number) => {
vars.value.splice(index, 1)
const removeEnvironmentVariable = (id: number) => {
const index = vars.value.findIndex((e) => e.id === id)
if (index !== -1) {
vars.value.splice(index, 1)
}
}
const isLoading = ref(false)
@@ -278,52 +370,102 @@ const saveEnvironment = async () => {
)
)
const secretVariables = pipe(
filterdVariables,
A.filterMapWithIndex((i, e) =>
e.secret ? O.some({ key: e.key, value: e.value, varIndex: i }) : O.none
)
)
const variables = pipe(
filterdVariables,
A.map((e) =>
e.secret ? { key: e.key, secret: e.secret, value: undefined } : e
)
)
const environmentUpdated: Environment = {
v: 1,
id: editingID.value ?? "",
name: editingName.value,
variables,
}
if (props.action === "new") {
platform.analytics?.logEvent({
type: "HOPP_CREATE_ENVIRONMENT",
workspaceType: "team",
})
await pipe(
createTeamEnvironment(
JSON.stringify(filterdVariables),
props.editingTeamId,
editingName.value
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
},
() => {
hideModal()
toast.success(`${t("environment.created")}`)
}
)
)()
if (!props.isViewer) {
await pipe(
createTeamEnvironment(
JSON.stringify(environmentUpdated.variables),
props.editingTeamId,
environmentUpdated.name
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
isLoading.value = false
},
(res) => {
const envID = res.createTeamEnvironment.id
if (envID) {
secretEnvironmentService.addSecretEnvironment(
envID,
secretVariables
)
}
hideModal()
toast.success(`${t("environment.created")}`)
isLoading.value = false
}
)
)()
}
} else {
if (!props.editingEnvironment) {
console.error("No Environment Found")
return
}
await pipe(
updateTeamEnvironment(
JSON.stringify(filterdVariables),
props.editingEnvironment.id,
editingName.value
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
},
() => {
hideModal()
toast.success(`${t("environment.updated")}`)
}
if (editingID.value) {
secretEnvironmentService.addSecretEnvironment(
editingID.value,
secretVariables
)
)()
// If the user is a viewer, we don't need to update the environment in BE
// just update the secret environment in the local storage
if (props.isViewer) {
hideModal()
toast.success(`${t("environment.updated")}`)
}
}
if (!props.isViewer) {
await pipe(
updateTeamEnvironment(
JSON.stringify(environmentUpdated.variables),
props.editingEnvironment.id,
environmentUpdated.name
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
isLoading.value = false
},
() => {
hideModal()
toast.success(`${t("environment.updated")}`)
isLoading.value = false
}
)
)()
}
}
isLoading.value = false
@@ -331,6 +473,7 @@ const saveEnvironment = async () => {
const hideModal = () => {
editingName.value = null
selectedEnvOption.value = "variables"
emit("hide-modal")
}

View File

@@ -19,7 +19,6 @@
</span>
<span>
<tippy
v-if="!isViewer"
ref="options"
interactive
trigger="click"
@@ -57,6 +56,7 @@
/>
<HoppSmartItem
v-if="!isViewer"
ref="duplicate"
:icon="IconCopy"
:label="`${t('action.duplicate')}`"
@@ -69,6 +69,7 @@
"
/>
<HoppSmartItem
v-if="!isViewer"
ref="exportAsJsonEl"
:icon="IconEdit"
:label="`${t('export.as_json')}`"
@@ -81,6 +82,7 @@
"
/>
<HoppSmartItem
v-if="!isViewer"
ref="deleteAction"
:icon="IconTrash2"
:label="`${t('action.delete')}`"
@@ -124,6 +126,8 @@ import IconMoreVertical from "~icons/lucide/more-vertical"
import { TippyComponent } from "vue-tippy"
import { HoppSmartItem } from "@hoppscotch/ui"
import { exportAsJSON } from "~/helpers/import-export/export/environment"
import { useService } from "dioc/vue"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
const t = useI18n()
const toast = useToast()
@@ -137,6 +141,8 @@ const emit = defineEmits<{
(e: "edit-environment"): void
}>()
const secretEnvironmentService = useService(SecretEnvironmentService)
const confirmRemove = ref(false)
const exportEnvironmentAsJSON = () =>
@@ -161,6 +167,7 @@ const removeEnvironment = () => {
},
() => {
toast.success(`${t("team_environment.deleted")}`)
secretEnvironmentService.deleteSecretEnvironment(props.environment.id)
}
)
)()

View File

@@ -105,6 +105,7 @@
:editing-environment="editingEnvironment"
:editing-team-id="team?.id"
:editing-variable-name="editingVariableName"
:is-secret-option-selected="secretOptionSelected"
:is-viewer="team?.myRole === 'VIEWER'"
@hide-modal="displayModalEdit(false)"
/>
@@ -148,6 +149,7 @@ const showModalDetails = ref(false)
const action = ref<"new" | "edit">("edit")
const editingEnvironment = ref<TeamEnvironment | null>(null)
const editingVariableName = ref("")
const secretOptionSelected = ref(false)
const isTeamViewer = computed(() => props.team?.myRole === "VIEWER")
@@ -171,6 +173,8 @@ const editEnvironment = (environment: TeamEnvironment | null) => {
}
const resetSelectedData = () => {
editingEnvironment.value = null
editingVariableName.value = ""
secretOptionSelected.value = false
}
const getErrorMessage = (err: GQLError<string>) => {
@@ -187,12 +191,15 @@ const getErrorMessage = (err: GQLError<string>) => {
defineActionHandler(
"modals.team.environment.edit",
({ envName, variableName }) => {
({ envName, variableName, isSecret }) => {
if (variableName) editingVariableName.value = variableName
const teamEnvToEdit = props.teamEnvironments.find(
(environment) => environment.environment.name === envName
)
if (teamEnvToEdit) editEnvironment(teamEnvToEdit)
if (teamEnvToEdit) {
editEnvironment(teamEnvToEdit)
secretOptionSelected.value = isSecret ?? false
}
}
)
</script>

View File

@@ -187,6 +187,8 @@ const copyCodeIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
const requestCode = computed(() => {
const aggregateEnvs = getAggregateEnvs()
const env: Environment = {
v: 1,
id: "env",
name: "Env",
variables: aggregateEnvs,
}

View File

@@ -553,7 +553,7 @@ const clearContent = () => {
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, getAggregateEnvs())
const computedHeaders = computed(() =>
getComputedHeaders(request.value, aggregateEnvs.value).map(
getComputedHeaders(request.value, aggregateEnvs.value, false).map(
(header, index) => ({
id: `header-${index}`,
...header,
@@ -606,7 +606,8 @@ const inheritedProperties = computed(() => {
const computedAuthHeader = getComputedAuthHeaders(
aggregateEnvs.value,
request.value,
props.inheritedProperties.auth.inheritedAuth
props.inheritedProperties.auth.inheritedAuth,
false
)[0]
if (

View File

@@ -112,7 +112,7 @@ const handleAccessTokenRequest = async () => {
}
const envs = getCombinedEnvVariables()
const envVars = [...envs.selected, ...envs.global]
const envVars = [...envs.selected.variables, ...envs.global]
try {
const tokenReqParams = {

View File

@@ -64,7 +64,6 @@
:icon="IconShare2"
:label="t('tab.share_tab_request')"
:shortcut="['S']"
:new="true"
@click="
() => {
emit('share-tab-request')

View File

@@ -211,7 +211,6 @@ import { useI18n } from "@composables/i18n"
import {
globalEnv$,
selectedEnvironmentIndex$,
setGlobalEnvVariables,
setSelectedEnvironmentIndex,
} from "~/newstore/environments"
import { HoppTestResult } from "~/helpers/types/HoppTestResult"
@@ -225,6 +224,7 @@ import { useColorMode } from "~/composables/theming"
import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue"
import { WorkspaceService } from "~/services/workspace.service"
import { invokeAction } from "~/helpers/actions"
const props = defineProps<{
modelValue: HoppTestResult | null | undefined
@@ -304,9 +304,10 @@ const globalHasAdditions = computed(() => {
const addEnvToGlobal = () => {
if (!testResults.value?.envDiff.selected.additions) return
setGlobalEnvVariables([
...globalEnvVars.value,
...testResults.value.envDiff.selected.additions,
])
invokeAction("modals.global.environment.update", {
variables: testResults.value.envDiff.selected.additions,
isSecret: false,
})
}
</script>

View File

@@ -3,7 +3,18 @@
<div
class="no-scrollbar absolute inset-0 flex flex-1 divide-x divide-dividerLight overflow-x-auto"
>
<input
v-if="isSecret"
id="secret"
v-model="secretText"
name="secret"
:placeholder="t('environment.secret_value')"
class="flex flex-1 bg-transparent px-4"
:class="styles"
type="password"
/>
<div
v-else
ref="editor"
:placeholder="placeholder"
class="flex flex-1"
@@ -11,7 +22,14 @@
@click="emit('click', $event)"
@keydown="handleKeystroke"
@focusin="showSuggestionPopover = true"
></div>
/>
<HoppButtonSecondary
v-if="secret"
v-tippy="{ theme: 'tooltip' }"
:title="isSecret ? t('action.show_secret') : t('action.hide_secret')"
:icon="isSecret ? IconEyeoff : IconEye"
@click="toggleSecret"
/>
<AppInspection
:inspection-results="inspectionResults"
class="sticky inset-y-0 right-0 rounded-r bg-primary"
@@ -61,18 +79,29 @@ import { history, historyKeymap } from "@codemirror/commands"
import { inputTheme } from "~/helpers/editor/themes/baseTheme"
import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment"
import { useReadonlyStream } from "@composables/stream"
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
import {
AggregateEnvironment,
aggregateEnvsWithSecrets$,
} from "~/newstore/environments"
import { platform } from "~/platform"
import { onClickOutside, useDebounceFn } from "@vueuse/core"
import { InspectorResult } from "~/services/inspection"
import { invokeAction } from "~/helpers/actions"
import { Environment } from "@hoppscotch/data"
import { useI18n } from "~/composables/i18n"
import IconEye from "~icons/lucide/eye"
import IconEyeoff from "~icons/lucide/eye-off"
const t = useI18n()
type Env = Environment["variables"][number] & { source: string }
const props = withDefaults(
defineProps<{
modelValue?: string
placeholder?: string
styles?: string
envs?: { key: string; value: string; source: string }[] | null
envs?: Env[] | null
focus?: boolean
selectTextOnMount?: boolean
environmentHighlights?: boolean
@@ -80,6 +109,7 @@ const props = withDefaults(
autoCompleteSource?: string[]
inspectionResults?: InspectorResult[] | undefined
contextMenuEnabled?: boolean
secret?: boolean
}>(),
{
modelValue: "",
@@ -93,6 +123,7 @@ const props = withDefaults(
inspectionResult: undefined,
inspectionResults: undefined,
contextMenuEnabled: true,
secret: false,
}
)
@@ -118,10 +149,27 @@ const showSuggestionPopover = ref(false)
const suggestionsMenu = ref<any | null>(null)
const autoCompleteWrapper = ref<any | null>(null)
const isSecret = ref(props.secret)
const secretText = ref(props.modelValue)
watch(
() => secretText.value,
(newVal) => {
if (isSecret.value) {
updateModelValue(newVal)
}
}
)
onClickOutside(autoCompleteWrapper, () => {
showSuggestionPopover.value = false
})
const toggleSecret = () => {
isSecret.value = !isSecret.value
}
//filter autocompleteSource with unique values
const uniqueAutoCompleteSource = computed(() => {
if (props.autoCompleteSource) {
@@ -169,8 +217,6 @@ watch(
)
const handleKeystroke = (ev: KeyboardEvent) => {
if (!props.autoCompleteSource) return
if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(ev.key)) {
ev.preventDefault()
}
@@ -307,19 +353,28 @@ watch(
let clipboardEv: ClipboardEvent | null = null
let pastedValue: string | null = null
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, []) as Ref<
const aggregateEnvs = useReadonlyStream(aggregateEnvsWithSecrets$, []) as Ref<
AggregateEnvironment[]
>
const envVars = computed(() =>
props.envs
? props.envs.map((x) => ({
key: x.key,
value: x.value,
sourceEnv: x.source,
}))
const envVars = computed(() => {
return props.envs
? props.envs.map((x) => {
if (x.secret) {
return {
key: x.key,
sourceEnv: "source" in x ? x.source : null,
value: "********",
}
}
return {
key: x.key,
value: x.value,
sourceEnv: "source" in x ? x.source : null,
}
})
: aggregateEnvs.value
)
})
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
@@ -363,17 +418,28 @@ const initView = (el: any) => {
el.addEventListener("keyup", debounceFn)
}
const extensions: Extension = getExtensions(props.readonly || isSecret.value)
view.value = new EditorView({
parent: el,
state: EditorState.create({
doc: props.modelValue,
extensions,
}),
})
}
const getExtensions = (readonly: boolean): Extension => {
const extensions: Extension = [
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
EditorView.updateListener.of((update) => {
if (props.readonly) {
if (readonly) {
update.view.contentDOM.inputMode = "none"
}
}),
EditorState.changeFilter.of(() => !props.readonly),
EditorState.changeFilter.of(() => !readonly),
inputTheme,
props.readonly
readonly
? EditorView.theme({
".cm-content": {
caretColor: "var(--secondary-dark-color)",
@@ -384,6 +450,7 @@ const initView = (el: any) => {
})
: EditorView.theme({}),
tooltips({
parent: document.body,
position: "absolute",
}),
props.environmentHighlights ? envTooltipPlugin : [],
@@ -405,7 +472,8 @@ const initView = (el: any) => {
ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
if (props.readonly) return
if (readonly) return
if (update.docChanged) {
const prevValue = clone(cachedValue.value)
@@ -454,14 +522,7 @@ const initView = (el: any) => {
history(),
keymap.of([...historyKeymap]),
]
view.value = new EditorView({
parent: el,
state: EditorState.create({
doc: props.modelValue,
extensions,
}),
})
return extensions
}
const triggerTextSelection = () => {
@@ -474,11 +535,11 @@ const triggerTextSelection = () => {
})
})
}
onMounted(() => {
if (editor.value) {
if (!view.value) initView(editor.value)
if (props.selectTextOnMount) triggerTextSelection()
if (props.focus) view.value?.focus()
platform.ui?.onCodemirrorInstanceMount?.(editor.value)
}
})

View File

@@ -4,6 +4,7 @@ import {
ViewPlugin,
ViewUpdate,
placeholder,
tooltips,
} from "@codemirror/view"
import {
Extension,
@@ -269,6 +270,7 @@ export function useCodemirror(
basicSetup,
baseTheme,
syntaxHighlighting(baseHighlightStyle, { fallback: true }),
ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
@@ -318,6 +320,7 @@ export function useCodemirror(
}
}
),
EditorView.domEventHandlers({
scroll(event) {
if (event.target && options.contextMenuEnabled) {
@@ -359,6 +362,10 @@ export function useCodemirror(
run: indentLess,
},
]),
tooltips({
parent: document.body,
position: "absolute",
}),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
additionalExts.of(options.additionalExts ?? []),
]

View File

@@ -30,6 +30,13 @@ import { HoppRESTResponse } from "./types/HoppRESTResponse"
import { HoppTestData, HoppTestResult } from "./types/HoppTestResult"
import { getEffectiveRESTRequest } from "./utils/EffectiveURL"
import { isJSONContentType } from "./utils/contenttypes"
import {
SecretEnvironmentService,
SecretVariable,
} from "~/services/secret-environment.service"
import { getService } from "~/modules/dioc"
const secretEnvironmentService = getService(SecretEnvironmentService)
const getTestableBody = (
res: HoppRESTResponse & { type: "success" | "fail" }
@@ -58,15 +65,63 @@ const getTestableBody = (
return x
}
const combineEnvVariables = (env: {
const combineEnvVariables = (envs: {
global: Environment["variables"]
selected: Environment["variables"]
}) => [...env.selected, ...env.global]
}) => [...envs.selected, ...envs.global]
export const executedResponses$ = new Subject<
HoppRESTResponse & { type: "success" | "fail " }
>()
/**
* Used to update the environment schema with the secret variables
* and store the secret variable values in the secret environment service
* @param envs The environment variables to update
* @param type Whether the environment variables are global or selected
* @returns the updated environment variables
*/
const updateEnvironmentsWithSecret = (
envs: Environment["variables"] &
{
secret: true
value: string | undefined
key: string
}[],
type: "global" | "selected"
) => {
const currentEnvID =
type === "selected" ? getCurrentEnvironment().id : "Global"
const updatedSecretEnvironments: SecretVariable[] = []
const updatedEnv = pipe(
envs,
A.mapWithIndex((index, e) => {
if (e.secret) {
updatedSecretEnvironments.push({
key: e.key,
value: e.value ?? "",
varIndex: index,
})
// delete the value from the environment
// so that it doesn't get saved in the environment
delete e.value
return e
}
return e
})
)
if (currentEnvID) {
secretEnvironmentService.addSecretEnvironment(
currentEnvID,
updatedSecretEnvironments
)
}
return updatedEnv
}
export function runRESTRequest$(
tab: Ref<HoppTab<HoppRESTDocument>>
): [
@@ -154,15 +209,36 @@ export function runRESTRequest$(
)
if (E.isRight(runResult)) {
const updatedGlobalEnvVariables = updateEnvironmentsWithSecret(
cloneDeep(runResult.right.envs.global),
"global"
)
const updatedSelectedEnvVariables = updateEnvironmentsWithSecret(
cloneDeep(runResult.right.envs.selected),
"selected"
)
// set the response in the tab so that multiple tabs can run request simultaneously
tab.value.document.response = res
tab.value.document.testResults = translateToSandboxTestResults(
runResult.right
const updatedRunResult = {
...runResult.right,
envs: {
global: updatedGlobalEnvVariables,
selected: updatedSelectedEnvVariables,
},
}
tab.value.document.testResults =
translateToSandboxTestResults(updatedRunResult)
setGlobalEnvVariables(
updateEnvironmentsWithSecret(
runResult.right.envs.global,
"global"
)
)
setGlobalEnvVariables(runResult.right.envs.global)
if (
environmentsStore.value.selectedEnvironmentIndex.type === "MY_ENV"
) {
@@ -173,8 +249,10 @@ export function runRESTRequest$(
updateEnvironment(
environmentsStore.value.selectedEnvironmentIndex.index,
{
...env,
variables: runResult.right.envs.selected,
name: env.name,
v: 1,
id: env.id ?? "",
variables: updatedRunResult.envs.selected,
}
)
} else if (
@@ -186,7 +264,7 @@ export function runRESTRequest$(
})
pipe(
updateTeamEnvironment(
JSON.stringify(runResult.right.envs.selected),
JSON.stringify(updatedRunResult.envs.selected),
environmentsStore.value.selectedEnvironmentIndex.teamEnvID,
env.name
)
@@ -275,7 +353,6 @@ function translateToSandboxTestResults(
const globals = cloneDeep(getGlobalVariables())
const env = getCurrentEnvironment()
return {
description: "",
expectResults: testDesc.tests.expectResults,

View File

@@ -5,7 +5,7 @@
import { Ref, onBeforeUnmount, onMounted, reactive, watch } from "vue"
import { BehaviorSubject } from "rxjs"
import { HoppRESTDocument } from "./rest/document"
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { Environment, HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { RESTOptionTabs } from "~/components/http/RequestOptions.vue"
import { HoppGQLSaveContext } from "./graphql/document"
import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue"
@@ -43,6 +43,7 @@ export type HoppAction =
| "modals.environment.new" // Add new environment
| "modals.environment.delete-selected" // Delete Selected Environment
| "modals.my.environment.edit" // Edit current personal environment
| "modals.global.environment.update" // Update global environment
| "modals.team.environment.edit" // Edit current team environment
| "modals.team.new" // Add new team
| "modals.team.edit" // Edit selected team
@@ -93,13 +94,19 @@ type HoppActionArgsMap = {
}
text: string | null
}
"modals.global.environment.update": {
variables?: Environment["variables"]
isSecret?: boolean
}
"modals.my.environment.edit": {
envName: string
variableName?: string
isSecret?: boolean
}
"modals.team.environment.edit": {
envName: string
variableName?: string
isSecret?: boolean
}
"modals.team.delete": {
teamId: string

View File

@@ -1,7 +1,12 @@
mutation CreateTeamEnvironment($variables: String!,$teamID: ID!,$name: String!){
createTeamEnvironment( variables: $variables ,teamID: $teamID ,name: $name){
mutation CreateTeamEnvironment(
$variables: String!
$teamID: ID!
$name: String!
) {
createTeamEnvironment(variables: $variables, teamID: $teamID, name: $name) {
variables
name
teamID
id
}
}
}

View File

@@ -12,8 +12,8 @@ import { parseTemplateStringE } from "@hoppscotch/data"
import { StreamSubscriberFunc } from "@composables/stream"
import {
AggregateEnvironment,
aggregateEnvs$,
getAggregateEnvs,
aggregateEnvsWithSecrets$,
getAggregateEnvsWithSecrets,
getSelectedEnvironmentType,
} from "~/newstore/environments"
import { invokeAction } from "~/helpers/actions"
@@ -66,7 +66,16 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
const envName = tooltipEnv?.sourceEnv ?? "Choose an Environment"
const envValue = tooltipEnv?.value ?? "Not found"
let envValue = "Not Found"
if (!tooltipEnv?.secret && tooltipEnv?.value) envValue = tooltipEnv.value
else if (tooltipEnv?.secret && tooltipEnv.value) {
envValue = "******"
} else if (!tooltipEnv?.sourceEnv) {
envValue = "Not Found"
} else if (!tooltipEnv?.value) {
envValue = "Empty"
}
const result = parseTemplateStringE(envValue, aggregateEnvs)
@@ -89,6 +98,7 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
invokeAction(`modals.${action}.environment.edit`, {
envName,
variableName: parsedEnvKey,
isSecret: tooltipEnv?.secret,
})
})
editIcon.innerHTML = `<span class="inline-flex items-center justify-center my-1">${IconEdit}</span>`
@@ -171,9 +181,9 @@ export class HoppEnvironmentPlugin {
subscribeToStream: StreamSubscriberFunc,
private editorView: Ref<EditorView | undefined>
) {
this.envs = getAggregateEnvs()
this.envs = getAggregateEnvsWithSecrets()
subscribeToStream(aggregateEnvs$, (envs) => {
subscribeToStream(aggregateEnvsWithSecrets$, (envs) => {
this.envs = envs
this.editorView.value?.dispatch({

View File

@@ -12,8 +12,6 @@ const getEnvironmentJson = (
? cloneDeep(environmentObj.environment)
: cloneDeep(environmentObj)
delete newEnvironment.id
const environmentId =
environmentIndex || environmentIndex === 0
? environmentIndex

View File

@@ -1,22 +1,13 @@
import * as TE from "fp-ts/TaskEither"
import * as O from "fp-ts/Option"
import * as TE from "fp-ts/TaskEither"
import { entityReference } from "verzod"
import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { safeParseJSON } from "~/helpers/functional/json"
import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { Environment } from "@hoppscotch/data"
import { z } from "zod"
const hoppEnvSchema = z.object({
id: z.string().optional(),
name: z.string(),
variables: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
export const hoppEnvImporter = (content: string) => {
const parsedContent = safeParseJSON(content, true)
@@ -25,7 +16,9 @@ export const hoppEnvImporter = (content: string) => {
return TE.left(IMPORTER_INVALID_FILE_FORMAT)
}
const validationResult = z.array(hoppEnvSchema).safeParse(parsedContent.value)
const validationResult = z
.array(entityReference(Environment))
.safeParse(parsedContent.value)
if (!validationResult.success) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT)

View File

@@ -4,8 +4,9 @@ import * as O from "fp-ts/Option"
import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { z } from "zod"
import { Environment } from "@hoppscotch/data"
import { NonSecretEnvironment } from "@hoppscotch/data"
import { safeParseJSONOrYAML } from "~/helpers/functional/yaml"
import { uniqueId } from "lodash-es"
const insomniaResourcesSchema = z.object({
resources: z.array(
@@ -56,16 +57,18 @@ export const insomniaEnvImporter = (content: string) => {
return { ...envResource, data: stringifiedData }
})
const environments: Environment[] = []
const environments: NonSecretEnvironment[] = []
insomniaEnvs.forEach((insomniaEnv) => {
const parsedInsomniaEnv = insomniaEnvSchema.safeParse(insomniaEnv)
if (parsedInsomniaEnv.success) {
const environment: Environment = {
const environment: NonSecretEnvironment = {
id: uniqueId(),
v: 1,
name: parsedInsomniaEnv.data.name,
variables: Object.entries(parsedInsomniaEnv.data.data).map(
([key, value]) => ({ key, value })
([key, value]) => ({ key, value, secret: false })
),
}

View File

@@ -6,6 +6,7 @@ import { safeParseJSON } from "~/helpers/functional/json"
import { z } from "zod"
import { Environment } from "@hoppscotch/data"
import { uniqueId } from "lodash-es"
const postmanEnvSchema = z.object({
name: z.string(),
@@ -34,12 +35,14 @@ export const postmanEnvImporter = (content: string) => {
const postmanEnv = validationResult.data
const environment: Environment = {
id: uniqueId(),
v: 1,
name: postmanEnv.name,
variables: [],
}
postmanEnv.values.forEach(({ key, value }) =>
environment.variables.push({ key, value })
environment.variables.push({ key, value, secret: false })
)
return TE.right(environment)

View File

@@ -8,11 +8,71 @@ import {
getGlobalVariables,
} from "~/newstore/environments"
import { TestResult } from "@hoppscotch/js-sandbox"
import { getService } from "~/modules/dioc"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
export const getCombinedEnvVariables = () => ({
global: cloneDeep(getGlobalVariables()),
selected: cloneDeep(getCurrentEnvironment().variables),
})
const secretEnvironmentService = getService(SecretEnvironmentService)
const unsecretEnvironments = (
global: Environment["variables"],
selected: Environment
) => {
const resolvedGlobalWithSecrets = global.map((globalVar, index) => {
const secretVar = secretEnvironmentService.getSecretEnvironmentVariable(
"Global",
index
)
if (secretVar) {
return {
...globalVar,
value: secretVar.value,
}
} else if (!("value" in globalVar) || !globalVar.value) {
return {
...globalVar,
value: "",
}
}
return globalVar
})
const resolvedSelectedWithSecrets = selected.variables.map(
(selectedVar, index) => {
const secretVar = secretEnvironmentService.getSecretEnvironmentVariable(
selected.id,
index
)
if (secretVar) {
return {
...selectedVar,
value: secretVar.value,
}
} else if (!("value" in selectedVar) || !selectedVar.value) {
return {
...selectedVar,
value: "",
}
}
return selectedVar
}
)
return {
global: resolvedGlobalWithSecrets,
selected: resolvedSelectedWithSecrets,
}
}
export const getCombinedEnvVariables = () => {
const reformedVars = unsecretEnvironments(
getGlobalVariables(),
getCurrentEnvironment()
)
return {
global: cloneDeep(reformedVars.global),
selected: cloneDeep(reformedVars.selected),
}
}
export const getFinalEnvsFromPreRequest = (
script: string,

View File

@@ -118,6 +118,8 @@ export default class TeamEnvironmentAdapter {
id: x.id,
teamID: x.teamID,
environment: {
v: 1,
id: x.id,
name: x.name,
variables: JSON.parse(x.variables),
},
@@ -196,6 +198,8 @@ export default class TeamEnvironmentAdapter {
id: x.id,
teamID: x.teamID,
environment: {
v: 1,
id: x.id,
name: x.name,
variables: JSON.parse(x.variables),
},
@@ -249,6 +253,8 @@ export default class TeamEnvironmentAdapter {
id: x.id,
teamID: x.teamID,
environment: {
v: 1,
id: x.id,
name: x.name,
variables: JSON.parse(x.variables),
},

View File

@@ -45,7 +45,8 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
export const getComputedAuthHeaders = (
envVars: Environment["variables"],
req?: HoppRESTRequest,
auth?: HoppRESTRequest["auth"]
auth?: HoppRESTRequest["auth"],
parse = true
) => {
const request = auth ? { auth: auth ?? { authActive: false } } : req
// If Authorization header is also being user-defined, that takes priority
@@ -60,8 +61,12 @@ export const getComputedAuthHeaders = (
// TODO: Support a better b64 implementation than btoa ?
if (request.auth.authType === "basic") {
const username = parseTemplateString(request.auth.username, envVars)
const password = parseTemplateString(request.auth.password, envVars)
const username = parse
? parseTemplateString(request.auth.username, envVars)
: request.auth.username
const password = parse
? parseTemplateString(request.auth.password, envVars)
: request.auth.password
headers.push({
active: true,
@@ -75,7 +80,11 @@ export const getComputedAuthHeaders = (
headers.push({
active: true,
key: "Authorization",
value: `Bearer ${parseTemplateString(request.auth.token, envVars)}`,
value: `Bearer ${
parse
? parseTemplateString(request.auth.token, envVars)
: request.auth.token
}`,
})
} else if (request.auth.authType === "api-key") {
const { key, addTo } = request.auth
@@ -83,7 +92,9 @@ export const getComputedAuthHeaders = (
headers.push({
active: true,
key: parseTemplateString(key, envVars),
value: parseTemplateString(request.auth.value ?? "", envVars),
value: parse
? parseTemplateString(request.auth.value ?? "", envVars)
: request.auth.value ?? "",
})
}
}
@@ -133,10 +144,11 @@ export type ComputedHeader = {
*/
export const getComputedHeaders = (
req: HoppRESTRequest,
envVars: Environment["variables"]
envVars: Environment["variables"],
parse = true
): ComputedHeader[] => {
return [
...getComputedAuthHeaders(envVars, req).map((header) => ({
...getComputedAuthHeaders(envVars, req, undefined, parse).map((header) => ({
source: "auth" as const,
header,
})),

View File

@@ -1,10 +1,12 @@
import { Environment } from "@hoppscotch/data"
import { cloneDeep, isEqual } from "lodash-es"
import { cloneDeep, isEqual, uniqueId } from "lodash-es"
import { combineLatest, Observable } from "rxjs"
import { distinctUntilChanged, map, pluck } from "rxjs/operators"
import { getService } from "~/modules/dioc"
import DispatchingStore, {
defineDispatchers,
} from "~/newstore/DispatchingStore"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
export type SelectedEnvironmentIndex =
| { type: "NO_ENV_SELECTED" }
@@ -19,6 +21,8 @@ export type SelectedEnvironmentIndex =
const defaultEnvironmentsState = {
environments: [
{
v: 1,
id: uniqueId(),
name: "My Environment Variables",
variables: [],
},
@@ -33,6 +37,8 @@ const defaultEnvironmentsState = {
} as SelectedEnvironmentIndex,
}
const secretEnvironmentService = getService(SecretEnvironmentService)
type EnvironmentStore = typeof defaultEnvironmentsState
const dispatchers = defineDispatchers({
@@ -88,10 +94,13 @@ const dispatchers = defineDispatchers({
envID
? {
id: envID,
v: 1,
name,
variables,
}
: {
v: 1,
id: "",
name,
variables,
},
@@ -109,14 +118,12 @@ const dispatchers = defineDispatchers({
}
}
// remove the id, because this is a new environment & it will get its own id when syncing
delete newEnvironment["id"]
return {
environments: [
...environments,
{
...cloneDeep(newEnvironment),
id: uniqueId(),
name: `${newEnvironment.name} - Duplicate`,
},
],
@@ -184,14 +191,19 @@ const dispatchers = defineDispatchers({
},
addEnvironmentVariable(
{ environments }: EnvironmentStore,
{ envIndex, key, value }: { envIndex: number; key: string; value: string }
{
envIndex,
key,
value,
secret,
}: { envIndex: number; key: string; value: string; secret: boolean }
) {
return {
environments: environments.map((env, index) =>
index === envIndex
? {
...env,
variables: [...env.variables, { key, value }],
variables: [...env.variables, { key, value, secret }],
}
: env
),
@@ -219,7 +231,10 @@ const dispatchers = defineDispatchers({
{
envIndex,
vars,
}: { envIndex: number; vars: { key: string; value: string }[] }
}: {
envIndex: number
vars: { key: string; value: string; secret: boolean }[]
}
) {
return {
environments: environments.map((env, index) =>
@@ -253,7 +268,7 @@ const dispatchers = defineDispatchers({
...env,
variables: env.variables.map((v, vIndex) =>
vIndex === variableIndex
? { key: updatedKey, value: updatedValue }
? { key: updatedKey, value: updatedValue, secret: v.secret }
: v
),
}
@@ -343,6 +358,8 @@ export const currentEnvironment$: Observable<Environment | undefined> =
if (selectedEnvironmentIndex.type === "NO_ENV_SELECTED") {
const env: Environment = {
name: "No environment",
v: 1,
id: "",
variables: [],
}
return env
@@ -356,6 +373,7 @@ export const currentEnvironment$: Observable<Environment | undefined> =
export type AggregateEnvironment = {
key: string
value: string
secret: boolean
sourceEnv: string
}
@@ -370,11 +388,11 @@ export const aggregateEnvs$: Observable<AggregateEnvironment[]> = combineLatest(
map(([selectedEnv, globalVars]) => {
const results: AggregateEnvironment[] = []
selectedEnv?.variables.forEach(({ key, value }) =>
results.push({ key, value, sourceEnv: selectedEnv.name })
selectedEnv?.variables.forEach(({ key, value, secret }) =>
results.push({ key, value, secret, sourceEnv: selectedEnv.name })
)
globalVars.forEach(({ key, value }) =>
results.push({ key, value, sourceEnv: "Global" })
globalVars.forEach(({ key, value, secret }) =>
results.push({ key, value, secret, sourceEnv: "Global" })
)
return results
@@ -384,32 +402,129 @@ export const aggregateEnvs$: Observable<AggregateEnvironment[]> = combineLatest(
export function getAggregateEnvs() {
const currentEnv = getCurrentEnvironment()
return [
...currentEnv.variables.map(
(x) =>
<AggregateEnvironment>{
key: x.key,
value: x.value,
sourceEnv: currentEnv.name,
}
),
...getGlobalVariables().map(
(x) =>
<AggregateEnvironment>{
key: x.key,
value: x.value,
sourceEnv: "Global",
}
),
...currentEnv.variables.map((x) => {
let value
if (!x.secret) {
value = x.value
}
return <AggregateEnvironment>{
key: x.key,
value,
secret: x.secret,
sourceEnv: currentEnv.name,
}
}),
...getGlobalVariables().map((x) => {
let value
if (!x.secret) {
value = x.value
}
return <AggregateEnvironment>{
key: x.key,
value,
secret: x.secret,
sourceEnv: "Global",
}
}),
]
}
export function getAggregateEnvsWithSecrets() {
const currentEnv = getCurrentEnvironment()
return [
...currentEnv.variables.map((x, index) => {
let value
if (x.secret) {
value = secretEnvironmentService.getSecretEnvironmentVariableValue(
currentEnv.id,
index
)
} else {
value = x.value
}
return <AggregateEnvironment>{
key: x.key,
value,
secret: x.secret,
sourceEnv: currentEnv.name,
}
}),
...getGlobalVariables().map((x, index) => {
let value
if (x.secret) {
value = secretEnvironmentService.getSecretEnvironmentVariableValue(
"Global",
index
)
} else {
value = x.value
}
return <AggregateEnvironment>{
key: x.key,
value,
secret: x.secret,
sourceEnv: "Global",
}
}),
]
}
export const aggregateEnvsWithSecrets$: Observable<AggregateEnvironment[]> =
combineLatest([currentEnvironment$, globalEnv$]).pipe(
map(([selectedEnv, globalVars]) => {
const results: AggregateEnvironment[] = []
selectedEnv?.variables.map((x, index) => {
let value
if (x.secret) {
value = secretEnvironmentService.getSecretEnvironmentVariableValue(
selectedEnv.id,
index
)
} else {
value = x.value
}
results.push({
key: x.key,
value: value ?? "",
secret: x.secret,
sourceEnv: selectedEnv.name,
})
})
globalVars.map((x, index) => {
let value
if (x.secret) {
value = secretEnvironmentService.getSecretEnvironmentVariableValue(
"Global",
index
)
} else {
value = x.value
}
results.push({
key: x.key,
value: value ?? "",
secret: x.secret,
sourceEnv: "Global",
})
})
return results
}),
distinctUntilChanged(isEqual)
)
export function getCurrentEnvironment(): Environment {
if (
environmentsStore.value.selectedEnvironmentIndex.type === "NO_ENV_SELECTED"
) {
return {
v: 1,
id: "",
name: "No environment",
variables: [],
}
@@ -589,7 +704,7 @@ export function updateEnvironment(envIndex: number, updatedEnv: Environment) {
export function setEnvironmentVariables(
envIndex: number,
vars: { key: string; value: string }[]
vars: { key: string; value: string; secret: boolean }[]
) {
environmentsStore.dispatch({
dispatcher: "setEnvironmentVariables",
@@ -602,7 +717,7 @@ export function setEnvironmentVariables(
export function addEnvironmentVariable(
envIndex: number,
{ key, value }: { key: string; value: string }
{ key, value, secret }: { key: string; value: string; secret: boolean }
) {
environmentsStore.dispatch({
dispatcher: "addEnvironmentVariable",
@@ -610,6 +725,7 @@ export function addEnvironmentVariable(
envIndex,
key,
value,
secret,
},
})
}

View File

@@ -0,0 +1,194 @@
import { describe, expect, it, beforeEach } from "vitest"
import { TestContainer } from "dioc/testing"
import { SecretEnvironmentService } from "../secret-environment.service"
describe("SecretEnvironmentService", () => {
let container: TestContainer
let service: SecretEnvironmentService
beforeEach(() => {
container = new TestContainer()
service = container.bind(SecretEnvironmentService)
})
describe("addSecretEnvironment", () => {
it("should add a new secret environment with the provided ID and secret variables", () => {
const id = "testEnvironment"
const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }]
service.addSecretEnvironment(id, secretVars)
expect(service.secretEnvironments.get(id)).toEqual(secretVars)
})
})
describe("getSecretEnvironment", () => {
it("should return the secret variables of the specified environment", () => {
const id = "testEnvironment"
const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }]
service.secretEnvironments.set(id, secretVars)
expect(service.getSecretEnvironment(id)).toEqual(secretVars)
})
it("should return undefined if the specified environment does not exist", () => {
const id = "nonExistentEnvironment"
expect(service.getSecretEnvironment(id)).toBeUndefined()
})
})
describe("getSecretEnvironmentVariable", () => {
it("should return the specified secret environment variable", () => {
const id = "testEnvironment"
const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }]
service.secretEnvironments.set(id, secretVars)
const result = service.getSecretEnvironmentVariable(id, 1)
expect(result).toEqual(secretVars[0])
})
it("should return undefined if the specified variable does not exist", () => {
const id = "testEnvironment"
const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }]
service.secretEnvironments.set(id, secretVars)
const result = service.getSecretEnvironmentVariable(id, 2)
expect(result).toBeUndefined()
})
it("should return undefined if the specified environment does not exist", () => {
const id = "nonExistentEnvironment"
const result = service.getSecretEnvironmentVariable(id, 1)
expect(result).toBeUndefined()
})
})
describe("getSecretEnvironmentVariableValue", () => {
it("should return the value of the specified secret environment variable", () => {
const id = "testEnvironment"
const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }]
service.secretEnvironments.set(id, secretVars)
const result = service.getSecretEnvironmentVariableValue(id, 1)
expect(result).toEqual(secretVars[0].value)
})
it("should return undefined if the specified variable does not exist", () => {
const id = "testEnvironment"
const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }]
service.secretEnvironments.set(id, secretVars)
const result = service.getSecretEnvironmentVariableValue(id, 2)
expect(result).toBeUndefined()
})
it("should return undefined if the specified environment does not exist", () => {
const id = "nonExistentEnvironment"
const result = service.getSecretEnvironmentVariableValue(id, 1)
expect(result).toBeUndefined()
})
})
describe("loadSecretEnvironmentsFromPersistedState", () => {
it("should load secret environments from the persisted state", () => {
const persistedState = {
testEnvironment: [{ key: "key1", value: "value1", varIndex: 1 }],
}
service.loadSecretEnvironmentsFromPersistedState(persistedState)
expect(service.secretEnvironments.size).toBe(1)
expect(service.secretEnvironments.get("testEnvironment")).toEqual([
{ key: "key1", value: "value1", varIndex: 1 },
])
})
})
describe("deleteSecretEnvironment", () => {
it("should delete the specified secret environment", () => {
const id = "testEnvironment"
const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }]
service.secretEnvironments.set(id, secretVars)
service.deleteSecretEnvironment(id)
expect(service.secretEnvironments.has(id)).toBe(false)
})
})
describe("removeSecretEnvironmentVariable", () => {
it("should remove the specified secret environment variable", () => {
const id = "testEnvironment"
const secretVars = [
{ key: "key1", value: "value1", varIndex: 1 },
{ key: "key2", value: "value2", varIndex: 2 },
]
service.secretEnvironments.set(id, secretVars)
service.removeSecretEnvironmentVariable(id, 1)
expect(service.secretEnvironments.get(id)).toEqual([
{ key: "key2", value: "value2", varIndex: 2 },
])
})
it("should do nothing if the specified variable does not exist", () => {
const id = "testEnvironment"
const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }]
service.secretEnvironments.set(id, secretVars)
service.removeSecretEnvironmentVariable(id, 2)
expect(service.secretEnvironments.get(id)).toEqual(secretVars)
})
})
describe("updateSecretEnvironmentID", () => {
it("should update the ID of the specified secret environment", () => {
const oldID = "oldEnvironment"
const newID = "newEnvironment"
const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }]
service.secretEnvironments.set(oldID, secretVars)
service.updateSecretEnvironmentID(oldID, newID)
expect(service.secretEnvironments.has(oldID)).toBe(false)
expect(service.secretEnvironments.get(newID)).toEqual(secretVars)
})
})
describe("persistableSecretEnvironments", () => {
it("should return a record of secret environments suitable for persistence", () => {
const secretVars = [
{ key: "key1", value: "value1", varIndex: 1 },
{ key: "key2", value: "value2", varIndex: 2 },
]
service.secretEnvironments.set("environment1", secretVars)
const persistedState = service.persistableSecretEnvironments.value
expect(persistedState).toEqual({
environment1: secretVars,
})
})
})
})

View File

@@ -15,9 +15,21 @@ vi.mock("~/newstore/environments", async () => {
return {
__esModule: true,
aggregateEnvs$: new BehaviorSubject([
{ key: "EXISTING_ENV_VAR", value: "test_value" },
aggregateEnvsWithSecrets$: new BehaviorSubject([
{ key: "EXISTING_ENV_VAR", value: "test_value", secret: false },
{ key: "EXISTING_ENV_VAR_2", value: "", secret: false },
]),
getCurrentEnvironment: () => ({
id: "1",
name: "some-env",
v: 1,
variables: {
key: "EXISTING_ENV_VAR",
value: "test_value",
secret: false,
},
}),
getSelectedEnvironmentType: () => "MY_ENV",
}
})
@@ -51,7 +63,7 @@ describe("EnvironmentInspectorService", () => {
expect(result.value).toContainEqual(
expect.objectContaining({
id: "environment",
id: "environment-not-found-0",
isApplicable: true,
text: {
type: "text",
@@ -91,7 +103,7 @@ describe("EnvironmentInspectorService", () => {
expect(result.value).toContainEqual(
expect.objectContaining({
id: "environment",
id: "environment-not-found-0",
isApplicable: true,
text: {
type: "text",
@@ -134,7 +146,7 @@ describe("EnvironmentInspectorService", () => {
expect(result.value).toContainEqual(
expect.objectContaining({
id: "environment",
id: "environment-not-found-0",
isApplicable: true,
text: {
type: "text",
@@ -161,5 +173,103 @@ describe("EnvironmentInspectorService", () => {
expect(result.value).toHaveLength(0)
})
it("should return an inspector result when the URL contains empty value in a environment variable", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "<<EXISTING_ENV_VAR_2>>",
})
const result = envInspector.getInspections(req)
expect(result.value).toHaveLength(1)
})
it("should not return an inspector result when the URL contains non empty value in a environemnt variable", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "<<EXISTING_ENV_VAR>>",
})
const result = envInspector.getInspections(req)
expect(result.value).toHaveLength(0)
})
it("should return an inspector result when the headers contain empty value in a environment variable", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [
{ key: "<<EXISTING_ENV_VAR_2>>", value: "some-value", active: true },
],
})
const result = envInspector.getInspections(req)
expect(result.value).toHaveLength(1)
})
it("should not return an inspector result when the headers contain non empty value in a environemnt variable", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [
{ key: "<<EXISTING_ENV_VAR>>", value: "some-value", active: true },
],
})
const result = envInspector.getInspections(req)
expect(result.value).toHaveLength(0)
})
it("should return an inspector result when the params contain empty value in a environment variable", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [],
params: [
{ key: "<<EXISTING_ENV_VAR_2>>", value: "some-value", active: true },
],
})
const result = envInspector.getInspections(req)
expect(result.value).toHaveLength(1)
})
it("should not return an inspector result when the params contain non empty value in a environemnt variable", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [],
params: [
{ key: "<<EXISTING_ENV_VAR>>", value: "some-value", active: true },
],
})
const result = envInspector.getInspections(req)
expect(result.value).toHaveLength(0)
})
})
})

View File

@@ -9,7 +9,11 @@ import { Service } from "dioc"
import { Ref, markRaw } from "vue"
import IconPlusCircle from "~icons/lucide/plus-circle"
import { HoppRESTRequest } from "@hoppscotch/data"
import { aggregateEnvs$ } from "~/newstore/environments"
import {
aggregateEnvsWithSecrets$,
getCurrentEnvironment,
getSelectedEnvironmentType,
} from "~/newstore/environments"
import { invokeAction } from "~/helpers/actions"
import { computed } from "vue"
import { useStreamStatic } from "~/composables/stream"
@@ -36,9 +40,13 @@ export class EnvironmentInspectorService extends Service implements Inspector {
private readonly inspection = this.bind(InspectionService)
private aggregateEnvs = useStreamStatic(aggregateEnvs$, [], () => {
/* noop */
})[0]
private aggregateEnvsWithSecrets = useStreamStatic(
aggregateEnvsWithSecrets$,
[],
() => {
/* noop */
}
)[0]
constructor() {
super()
@@ -49,9 +57,8 @@ export class EnvironmentInspectorService extends Service implements Inspector {
/**
* Validates the environment variables in the target array
* @param target The target array to validate
* @param results The results array to push the results to
* @param locations The location where results are to be displayed
* @returns The results array
* @returns The results array containing the results of the validation
*/
private validateEnvironmentVariables = (
target: any[],
@@ -59,7 +66,7 @@ export class EnvironmentInspectorService extends Service implements Inspector {
) => {
const newErrors: InspectorResult[] = []
const envKeys = this.aggregateEnvs.value.map((e) => e.key)
const envKeys = this.aggregateEnvsWithSecrets.value.map((e) => e.key)
target.forEach((element, index) => {
if (isENVInString(element)) {
@@ -68,29 +75,20 @@ export class EnvironmentInspectorService extends Service implements Inspector {
if (extractedEnv) {
extractedEnv.forEach((exEnv: string) => {
const formattedExEnv = exEnv.slice(2, -2)
let itemLocation: InspectorLocation
if (locations.type === "header") {
itemLocation = {
type: "header",
position: locations.position,
index: index,
key: element,
}
} else if (locations.type === "parameter") {
itemLocation = {
type: "parameter",
position: locations.position,
index: index,
key: element,
}
} else {
itemLocation = {
type: "url",
}
const itemLocation: InspectorLocation = {
type: locations.type,
position:
locations.type === "url" ||
locations.type === "body" ||
locations.type === "response"
? "key"
: locations.position,
index: index,
key: element,
}
if (!envKeys.includes(formattedExEnv)) {
newErrors.push({
id: "environment",
id: `environment-not-found-${newErrors.length}`,
text: {
type: "text",
text: this.t("inspections.environment.not_found", {
@@ -112,7 +110,7 @@ export class EnvironmentInspectorService extends Service implements Inspector {
locations: itemLocation,
doc: {
text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/",
link: "https://docs.hoppscotch.io/documentation/features/inspections",
},
})
}
@@ -124,6 +122,103 @@ export class EnvironmentInspectorService extends Service implements Inspector {
return newErrors
}
/**
* Checks if the environment variables in the target array are empty
* @param target The target array to validate
* @param locations The location where results are to be displayed
* @returns The results array containing the results of the validation
*/
private validateEmptyEnvironmentVariables = (
target: any[],
locations: InspectorLocation
) => {
const newErrors: InspectorResult[] = []
target.forEach((element, index) => {
if (isENVInString(element)) {
const extractedEnv = element.match(HOPP_ENVIRONMENT_REGEX)
if (extractedEnv) {
extractedEnv.forEach((exEnv: string) => {
const formattedExEnv = exEnv.slice(2, -2)
this.aggregateEnvsWithSecrets.value.forEach((env) => {
if (env.key === formattedExEnv) {
if (env.value === "") {
const itemLocation: InspectorLocation = {
type: locations.type,
position:
locations.type === "url" ||
locations.type === "body" ||
locations.type === "response"
? "key"
: locations.position,
index: index,
key: element,
}
const currentSelectedEnvironment = getCurrentEnvironment()
const currentEnvironmentType = getSelectedEnvironmentType()
let invokeActionType:
| "modals.my.environment.edit"
| "modals.team.environment.edit"
| "modals.global.environment.update" =
"modals.my.environment.edit"
if (env.sourceEnv === "Global") {
invokeActionType = "modals.global.environment.update"
} else if (currentEnvironmentType === "MY_ENV") {
invokeActionType = "modals.my.environment.edit"
} else if (currentEnvironmentType === "TEAM_ENV") {
invokeActionType = "modals.team.environment.edit"
} else {
invokeActionType = "modals.my.environment.edit"
}
newErrors.push({
id: `environment-empty-${newErrors.length}`,
text: {
type: "text",
text: this.t("inspections.environment.empty_value", {
variable: exEnv,
}),
},
icon: markRaw(IconPlusCircle),
action: {
text: this.t(
"inspections.environment.add_environment_value"
),
apply: () => {
invokeAction(invokeActionType, {
envName:
env.sourceEnv !== "Global"
? currentSelectedEnvironment.name
: "Global",
variableName: formattedExEnv,
isSecret: env.secret,
})
},
},
severity: 2,
isApplicable: true,
locations: itemLocation,
doc: {
text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/documentation/features/inspections",
},
})
}
}
})
})
}
}
})
return newErrors
}
getInspections(req: Readonly<Ref<HoppRESTRequest>>) {
return computed(() => {
const results: InspectorResult[] = []
@@ -132,12 +227,25 @@ export class EnvironmentInspectorService extends Service implements Inspector {
const params = req.value.params
/**
* Validate the environment variables in the URL
*/
const url = req.value.endpoint
results.push(
...this.validateEnvironmentVariables([req.value.endpoint], {
...this.validateEnvironmentVariables([url], {
type: "url",
})
)
results.push(
...this.validateEmptyEnvironmentVariables([url], {
type: "url",
})
)
/**
* Validate the environment variables in the headers
*/
const headerKeys = Object.values(headers).map((header) => header.key)
results.push(
@@ -146,6 +254,12 @@ export class EnvironmentInspectorService extends Service implements Inspector {
position: "key",
})
)
results.push(
...this.validateEmptyEnvironmentVariables(headerKeys, {
type: "header",
position: "key",
})
)
const headerValues = Object.values(headers).map((header) => header.value)
@@ -155,7 +269,16 @@ export class EnvironmentInspectorService extends Service implements Inspector {
position: "value",
})
)
results.push(
...this.validateEmptyEnvironmentVariables(headerValues, {
type: "header",
position: "value",
})
)
/**
* Validate the environment variables in the parameters
*/
const paramsKeys = Object.values(params).map((param) => param.key)
results.push(
@@ -164,6 +287,12 @@ export class EnvironmentInspectorService extends Service implements Inspector {
position: "key",
})
)
results.push(
...this.validateEmptyEnvironmentVariables(paramsKeys, {
type: "parameter",
position: "key",
})
)
const paramsValues = Object.values(params).map((param) => param.value)
@@ -174,6 +303,13 @@ export class EnvironmentInspectorService extends Service implements Inspector {
})
)
results.push(
...this.validateEmptyEnvironmentVariables(paramsValues, {
type: "parameter",
position: "value",
})
)
return results
})
}

View File

@@ -67,7 +67,7 @@ export class HeaderInspectorService extends Service implements Inspector {
},
doc: {
text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/",
link: "https://docs.hoppscotch.io/documentation/features/inspections",
},
})
}

View File

@@ -67,7 +67,7 @@ export class ResponseInspectorService extends Service implements Inspector {
},
doc: {
text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/",
link: "https://docs.hoppscotch.io/documentation/features/inspections",
},
})
}

View File

@@ -4,6 +4,7 @@ import { HoppGQLDocument } from "~/helpers/graphql/document"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history"
import { SettingsDef, getDefaultSettings } from "~/newstore/settings"
import { SecretVariable } from "~/services/secret-environment.service"
import { PersistableTabState } from "~/services/tab"
type VUEX_DATA = {
@@ -19,7 +20,7 @@ const DEFAULT_SETTINGS = getDefaultSettings()
export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
{
v: 1,
v: 2,
name: "Echo",
folders: [],
requests: [
@@ -36,12 +37,14 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
body: { contentType: null, body: null },
},
],
auth: { authType: "none", authActive: true },
headers: [],
},
]
export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
{
v: 1,
v: 2,
name: "Echo",
folders: [],
requests: [
@@ -55,20 +58,30 @@ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
auth: { authType: "none", authActive: true },
},
],
auth: { authType: "none", authActive: true },
headers: [],
},
]
export const ENVIRONMENTS_MOCK: Environment[] = [
{
v: 1,
id: "ENV_1",
name: "globals",
variables: [
{
key: "test-global-key",
value: "test-global-value",
secret: false,
},
],
},
{ name: "Test", variables: [{ key: "test-key", value: "test-value" }] },
{
v: 1,
id: "ENV_2",
name: "Test",
variables: [{ key: "test-key", value: "test-value", secret: false }],
},
]
export const SELECTED_ENV_INDEX_MOCK = {
@@ -98,7 +111,7 @@ export const MQTT_REQUEST_MOCK = {
}
export const GLOBAL_ENV_MOCK: Environment["variables"] = [
{ key: "test-key", value: "test-value" },
{ key: "test-key", value: "test-value", secret: false },
]
export const VUEX_DATA_MOCK: VUEX_DATA = {
@@ -201,3 +214,11 @@ export const REST_TAB_STATE_MOCK: PersistableTabState<HoppRESTDocument> = {
},
],
}
export const SECRET_ENVIRONMENTS_MOCK: Record<string, SecretVariable> = {
clryz7ir7002al4162bsj0azg: {
key: "test-key",
value: "test-value",
varIndex: 1,
},
}

View File

@@ -56,12 +56,14 @@ import {
REST_COLLECTIONS_MOCK,
REST_HISTORY_MOCK,
REST_TAB_STATE_MOCK,
SECRET_ENVIRONMENTS_MOCK,
SELECTED_ENV_INDEX_MOCK,
SOCKET_IO_REQUEST_MOCK,
SSE_REQUEST_MOCK,
VUEX_DATA_MOCK,
WEBSOCKET_REQUEST_MOCK,
} from "./__mocks__"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
vi.mock("~/modules/i18n", () => {
return {
@@ -122,10 +124,12 @@ const spyOnSetItem = () => vi.spyOn(Storage.prototype, "setItem")
const bindPersistenceService = ({
mockGQLTabService = false,
mockRESTTabService = false,
mockSecretEnvironmentsService = false,
mock = {},
}: {
mockGQLTabService?: boolean
mockRESTTabService?: boolean
mockSecretEnvironmentsService?: boolean
mock?: Record<string, unknown>
} = {}) => {
const container = new TestContainer()
@@ -138,6 +142,10 @@ const bindPersistenceService = ({
container.bindMock(RESTTabService, mock)
}
if (mockSecretEnvironmentsService) {
container.bindMock(SecretEnvironmentService, mock)
}
container.bind(PersistenceService)
const service = container.bind(PersistenceService)
@@ -893,7 +901,12 @@ describe("PersistenceService", () => {
// Invalid shape for `environments`
const environments = [
// `entries` -> `variables`
{ name: "Test", entries: [{ key: "test-key", value: "test-value" }] },
{
v: 1,
id: "ENV_1",
name: "Test",
entries: [{ key: "test-key", value: "test-value", secret: false }],
},
]
window.localStorage.setItem(
@@ -1044,6 +1057,119 @@ describe("PersistenceService", () => {
})
})
describe("Setup secret Environments persistence", () => {
// Key read from localStorage across test cases
const secretEnvironmentsKey = "secretEnvironments"
const loadSecretEnvironmentsFromPersistedStateFn = vi.fn()
const mock = {
loadSecretEnvironmentsFromPersistedState:
loadSecretEnvironmentsFromPersistedStateFn,
}
it(`shows an error and sets the entry as a backup in localStorage if "${secretEnvironmentsKey}" read from localStorage doesn't match the schema`, () => {
// Invalid shape for `secretEnvironments`
const secretEnvironments = {
clryz7ir7002al4162bsj0azg: {
key: "ENV_KEY",
value: "ENV_VALUE",
},
}
window.localStorage.setItem(
secretEnvironmentsKey,
JSON.stringify(secretEnvironments)
)
const getItemSpy = spyOnGetItem()
const setItemSpy = spyOnSetItem()
invokeSetupLocalPersistence()
expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey)
expect(toastErrorFn).toHaveBeenCalledWith(
expect.stringContaining(secretEnvironmentsKey)
)
expect(setItemSpy).toHaveBeenCalledWith(
`${secretEnvironmentsKey}-backup`,
JSON.stringify(secretEnvironments)
)
})
it("loads secret environments from the state persisted in localStorage and sets watcher for `persistableSecretEnvironment`", () => {
const secretEnvironment = SECRET_ENVIRONMENTS_MOCK
window.localStorage.setItem(
secretEnvironmentsKey,
JSON.stringify(secretEnvironment)
)
const getItemSpy = spyOnGetItem()
invokeSetupLocalPersistence({
mockSecretEnvironmentsService: true,
mock,
})
expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey)
expect(toastErrorFn).not.toHaveBeenCalledWith(secretEnvironmentsKey)
expect(loadSecretEnvironmentsFromPersistedStateFn).toHaveBeenCalledWith(
secretEnvironment
)
expect(watchDebounced).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Function),
{ debounce: 500 }
)
})
it(`skips schema parsing and the loading of persisted secret environments if there is no "${secretEnvironmentsKey}" key present in localStorage`, () => {
window.localStorage.removeItem(secretEnvironmentsKey)
const getItemSpy = spyOnGetItem()
const setItemSpy = spyOnSetItem()
invokeSetupLocalPersistence({
mockSecretEnvironmentsService: true,
mock,
})
expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey)
expect(toastErrorFn).not.toHaveBeenCalledWith(secretEnvironmentsKey)
expect(setItemSpy).not.toHaveBeenCalled()
expect(watchDebounced).toHaveBeenCalled()
})
it("logs an error to the console on failing to parse persisted secret environments", () => {
window.localStorage.setItem(secretEnvironmentsKey, "invalid-json")
console.error = vi.fn()
const getItemSpy = spyOnGetItem()
const setItemSpy = spyOnSetItem()
invokeSetupLocalPersistence()
expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey)
expect(toastErrorFn).not.toHaveBeenCalledWith(secretEnvironmentsKey)
expect(setItemSpy).not.toHaveBeenCalled()
expect(console.error).toHaveBeenCalledWith(
`Failed parsing persisted secret environment, state:`,
window.localStorage.getItem(secretEnvironmentsKey)
)
expect(watchDebounced).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Function),
{ debounce: 500 }
)
})
})
describe("setup WebSocket persistence", () => {
// Key read from localStorage across test cases
const wsRequestKey = "WebsocketRequest"

View File

@@ -67,10 +67,12 @@ import {
SETTINGS_SCHEMA,
SOCKET_IO_REQUEST_SCHEMA,
SSE_REQUEST_SCHEMA,
SECRET_ENVIRONMENT_VARIABLE_SCHEMA,
THEME_COLOR_SCHEMA,
VUEX_SCHEMA,
WEBSOCKET_REQUEST_SCHEMA,
} from "./validation-schemas"
import { SecretEnvironmentService } from "../secret-environment.service"
/**
* This service compiles persistence logic across the codebase
@@ -81,6 +83,10 @@ export class PersistenceService extends Service {
private readonly restTabService = this.bind(RESTTabService)
private readonly gqlTabService = this.bind(GQLTabService)
private readonly secretEnvironmentService = this.bind(
SecretEnvironmentService
)
public hoppLocalConfigStorage: StorageLike = localStorage
constructor() {
@@ -438,6 +444,56 @@ export class PersistenceService extends Service {
})
}
private setupSecretEnvironmentsPersistence() {
const secretEnvironmentsKey = "secretEnvironments"
const secretEnvironmentsData = window.localStorage.getItem(
secretEnvironmentsKey
)
try {
if (secretEnvironmentsData) {
let parsedSecretEnvironmentsData = JSON.parse(secretEnvironmentsData)
// Validate data read from localStorage
const result = SECRET_ENVIRONMENT_VARIABLE_SCHEMA.safeParse(
parsedSecretEnvironmentsData
)
if (result.success) {
parsedSecretEnvironmentsData = result.data
} else {
this.showErrorToast(secretEnvironmentsKey)
window.localStorage.setItem(
`${secretEnvironmentsKey}-backup`,
JSON.stringify(parsedSecretEnvironmentsData)
)
}
this.secretEnvironmentService.loadSecretEnvironmentsFromPersistedState(
parsedSecretEnvironmentsData
)
}
} catch (e) {
console.error(
`Failed parsing persisted secret environment, state:`,
secretEnvironmentsData
)
}
watchDebounced(
this.secretEnvironmentService.persistableSecretEnvironments,
(newSecretEnvironment) => {
window.localStorage.setItem(
secretEnvironmentsKey,
JSON.stringify(newSecretEnvironment)
)
},
{
debounce: 500,
}
)
}
private setupSelectedEnvPersistence() {
const selectedEnvIndexKey = "selectedEnvIndex"
let selectedEnvIndexValue = JSON.parse(
@@ -697,6 +753,8 @@ export class PersistenceService extends Service {
this.setupSocketIOPersistence()
this.setupSSEPersistence()
this.setupMQTTPersistence()
this.setupSecretEnvironmentsPersistence()
}
/**

View File

@@ -160,7 +160,6 @@ export const SELECTED_ENV_INDEX_SCHEMA = z.nullable(
type: z.literal("TEAM_ENV"),
teamID: z.string(),
teamEnvID: z.string(),
// ! Versioned entity
environment: entityReference(Environment),
}),
])
@@ -212,13 +211,19 @@ export const MQTT_REQUEST_SCHEMA = z.nullable(
export const GLOBAL_ENV_SCHEMA = z.union([
z.array(z.never()),
z.array(
z
.object({
z.union([
z.object({
key: z.string(),
secret: z.literal(true),
}),
z.object({
key: z.string(),
value: z.string(),
})
.strict()
secret: z.literal(false),
}),
])
),
])
@@ -339,12 +344,34 @@ const HoppTestDataSchema = z.lazy(() =>
.strict()
)
const EnvironmentVariablesSchema = z
.object({
const EnvironmentVariablesSchema = z.union([
z.object({
key: z.string(),
value: z.string(),
})
.strict()
secret: z.literal(false),
}),
z.object({
key: z.string(),
secret: z.literal(true),
}),
])
export const SECRET_ENVIRONMENT_VARIABLE_SCHEMA = z.union([
z.object({}).strict(),
z.record(
z.string(),
z.array(
z
.object({
key: z.string(),
value: z.string(),
varIndex: z.number(),
})
.strict()
)
),
])
const HoppTestResultSchema = z
.object({
@@ -358,7 +385,11 @@ const HoppTestResultSchema = z
.object({
additions: z.array(EnvironmentVariablesSchema),
updations: z.array(
EnvironmentVariablesSchema.extend({ previousValue: z.string() })
EnvironmentVariablesSchema.refine((x) => !x.secret).and(
z.object({
previousValue: z.string(),
})
)
),
deletions: z.array(EnvironmentVariablesSchema),
})
@@ -367,7 +398,11 @@ const HoppTestResultSchema = z
.object({
additions: z.array(EnvironmentVariablesSchema),
updations: z.array(
EnvironmentVariablesSchema.extend({ previousValue: z.string() })
EnvironmentVariablesSchema.refine((x) => !x.secret).and(
z.object({
previousValue: z.string(),
})
)
),
deletions: z.array(EnvironmentVariablesSchema),
})

View File

@@ -0,0 +1,130 @@
import { Service } from "dioc"
import { reactive } from "vue"
import { computed } from "vue"
/**
* Defines a secret environment variable.
*/
export type SecretVariable = {
key: string
value: string
varIndex: number
}
/**
* This service is used to store and manage secret environments.
* The secret environments are not synced with the server.
* hence they are not persisted in the database. They are stored
* in the local storage of the browser.
*/
export class SecretEnvironmentService extends Service {
public static readonly ID = "SECRET_ENVIRONMENT_SERVICE"
/**
* Map of secret environments.
* The key is the ID of the secret environment.
* The value is the list of secret variables.
*/
public secretEnvironments = reactive(new Map<string, SecretVariable[]>())
constructor() {
super()
}
/**
* Add a new secret environment.
* @param id ID of the environment
* @param secretVars List of secret variables
*/
public addSecretEnvironment(id: string, secretVars: SecretVariable[]) {
this.secretEnvironments.set(id, secretVars)
}
/**
* Get a secret environment.
* @param id ID of the environment
*/
public getSecretEnvironment(id: string) {
return this.secretEnvironments.get(id)
}
/**
* Get a secret environment variable.
* @param id ID of the environment
* @param varIndex Index of the variable in the environment
*/
public getSecretEnvironmentVariable(id: string, varIndex: number) {
const secretVars = this.getSecretEnvironment(id)
return secretVars?.find((secretVar) => secretVar.varIndex === varIndex)
}
/**
* Used to get the value of a secret environment variable.
* @param id ID of the environment
* @param varIndex Index of the variable in the environment
= */
public getSecretEnvironmentVariableValue(id: string, varIndex: number) {
const secretVar = this.getSecretEnvironmentVariable(id, varIndex)
return secretVar?.value
}
/**
*
* @param secretEnvironments Used to load secret environments from persisted state.
*/
public loadSecretEnvironmentsFromPersistedState(
secretEnvironments: Record<string, SecretVariable[]>
) {
if (secretEnvironments) {
this.secretEnvironments.clear()
Object.entries(secretEnvironments).forEach(([id, secretVars]) => {
this.addSecretEnvironment(id, secretVars)
})
}
}
/**
* Delete a secret environment.
* @param id ID of the environment
*/
public deleteSecretEnvironment(id: string) {
this.secretEnvironments.delete(id)
}
/**
* Delete a secret environment variable.
* @param id ID of the environment
* @param varIndex Index of the variable in the environment
*/
public removeSecretEnvironmentVariable(id: string, varIndex: number) {
const secretVars = this.getSecretEnvironment(id)
const newSecretVars = secretVars?.filter(
(secretVar) => secretVar.varIndex !== varIndex
)
this.secretEnvironments.set(id, newSecretVars || [])
}
/**
* Used to update thye ID of a secret environment.
* Used while syncing with the server.
* @param oldID old ID of the environment
* @param newID new ID of the environment
*/
public updateSecretEnvironmentID(oldID: string, newID: string) {
const secretVars = this.getSecretEnvironment(oldID)
this.secretEnvironments.set(newID, secretVars || [])
this.secretEnvironments.delete(oldID)
}
/**
* Used to update the value of a secret environment variable.
*/
public persistableSecretEnvironments = computed(() => {
const secretEnvironments: Record<string, SecretVariable[]> = {}
this.secretEnvironments.forEach((secretVars, id) => {
secretEnvironments[id] = secretVars
})
return secretEnvironments
})
}

View File

@@ -248,9 +248,7 @@ export class EnvironmentsSpotlightSearcherService extends StaticSpotlightSearche
this.duplicateSelectedEnv()
break
case "edit_global_env":
invokeAction(`modals.my.environment.edit`, {
envName: "Global",
})
invokeAction(`modals.global.environment.update`, {})
break
case "duplicate_global_env":
this.duplicateGlobalEnv()

View File

@@ -2,22 +2,38 @@ import * as E from "fp-ts/Either"
import { pipe } from "fp-ts/function"
import { InferredEntity, createVersionedEntity } from "verzod"
import { z } from "zod"
import V0_VERSION from "./v/0"
import V1_VERSION from "./v/1"
const versionedObject = z.object({
v: z.number(),
})
export const Environment = createVersionedEntity({
latestVersion: 0,
latestVersion: 1,
versionMap: {
0: V0_VERSION
0: V0_VERSION,
1: V1_VERSION,
},
getVersion(data) {
const versionCheck = versionedObject.safeParse(data)
if (versionCheck.success) return versionCheck.data.v
// For V0 we have to check the schema
const result = V0_VERSION.schema.safeParse(data)
return result.success ? 0 : null
},
getVersion(x) {
return V0_VERSION.schema.safeParse(x).success
? 0
: null
}
})
export type Environment = InferredEntity<typeof Environment>
export type EnvironmentVariable = InferredEntity<
typeof Environment
>["variables"][number]
const REGEX_ENV_VAR = /<<([^>]*)>>/g // "<<myVariable>>"
/**
@@ -31,6 +47,8 @@ const ENV_MAX_EXPAND_LIMIT = 10
*/
const ENV_EXPAND_LOOP = "ENV_EXPAND_LOOP" as const
export const EnvironmentSchemaVersion = 1
export function parseBodyEnvVariablesE(
body: string,
env: Environment["variables"]
@@ -43,7 +61,11 @@ export function parseBodyEnvVariablesE(
const found = env.find(
(envVar) => envVar.key === key.replace(/[<>]/g, "")
)
return found ? found.value : key
if (found && "value" in found) {
return found.value
}
return key
})
depth++
@@ -68,7 +90,10 @@ export const parseBodyEnvVariables = (
export function parseTemplateStringE(
str: string,
variables: Environment["variables"]
variables:
| Environment["variables"]
| { secret: true; value: string; key: string }[],
maskValue = false
) {
if (!variables || !str) {
return E.right(str)
@@ -78,10 +103,21 @@ export function parseTemplateStringE(
let depth = 0
while (result.match(REGEX_ENV_VAR) != null && depth <= ENV_MAX_EXPAND_LIMIT) {
result = decodeURI(encodeURI(result)).replace(
REGEX_ENV_VAR,
(_, p1) => variables.find((x) => x.key === p1)?.value || ""
)
result = decodeURI(encodeURI(result)).replace(REGEX_ENV_VAR, (_, p1) => {
const variable = variables.find((x) => x && x.key === p1)
if (variable && "value" in variable) {
// Mask the value if it is a secret and explicitly specified
if (variable.secret && maskValue) {
return "*".repeat(
(variable as { secret: true; value: string; key: string }).value
.length
)
}
return variable.value
}
return ""
})
depth++
}
@@ -90,14 +126,52 @@ export function parseTemplateStringE(
: E.right(result)
}
export type NonSecretEnvironmentVariable = Extract<
EnvironmentVariable,
{ secret: false }
>
export type NonSecretEnvironment = Omit<Environment, "variables"> & {
variables: NonSecretEnvironmentVariable[]
}
/**
* @deprecated Use `parseTemplateStringE` instead
*/
export const parseTemplateString = (
str: string,
variables: Environment["variables"]
variables:
| Environment["variables"]
| { secret: true; value: string; key: string }[],
maskValue = false
) =>
pipe(
parseTemplateStringE(str, variables),
parseTemplateStringE(str, variables, maskValue),
E.getOrElse(() => str)
)
export const translateToNewEnvironmentVariables = (
x: any
): Environment["variables"][number] => {
return {
key: x.key,
value: x.value,
secret: false,
}
}
export const translateToNewEnvironment = (x: any): Environment => {
if (x.v && x.v === EnvironmentSchemaVersion) return x
// Legacy
const id = x.id ?? ""
const name = x.name ?? "Untitled"
const variables = (x.variables ?? []).map(translateToNewEnvironmentVariables)
return {
v: EnvironmentSchemaVersion,
id,
name,
variables,
}
}

View File

@@ -9,10 +9,10 @@ export const V0_SCHEMA = z.object({
key: z.string(),
value: z.string(),
})
)
),
})
export default defineVersion({
initial: true,
schema: V0_SCHEMA
schema: V0_SCHEMA,
})

View File

@@ -0,0 +1,42 @@
import { z } from "zod"
import { defineVersion } from "verzod"
import { V0_SCHEMA } from "./0"
export const V1_SCHEMA = z.object({
v: z.literal(1),
id: z.string(),
name: z.string(),
variables: z.array(
z.union([
z.object({
key: z.string(),
secret: z.literal(true),
}),
z.object({
key: z.string(),
value: z.string(),
secret: z.literal(false),
}),
])
),
})
export default defineVersion({
initial: false,
schema: V1_SCHEMA,
up(old: z.infer<typeof V0_SCHEMA>) {
const result: z.infer<typeof V1_SCHEMA> = {
...old,
v: 1,
id: old.id ?? "",
variables: old.variables.map((variable) => {
return {
...variable,
secret: false,
}
}),
}
return result
},
})

View File

@@ -12,16 +12,16 @@ describe("runPreRequestScript", () => {
{
global: [],
selected: [
{ key: "bob", value: "oldbob" },
{ key: "foo", value: "bar" },
{ key: "bob", value: "oldbob", secret: false },
{ key: "foo", value: "bar", secret: false },
],
}
)()
).resolves.toEqualRight({
global: [],
selected: [
{ key: "bob", value: "newbob" },
{ key: "foo", value: "bar" },
{ key: "bob", value: "newbob", secret: false },
{ key: "foo", value: "bar", secret: false },
],
})
})
@@ -35,8 +35,8 @@ describe("runPreRequestScript", () => {
{
global: [],
selected: [
{ key: "bob", value: "oldbob" },
{ key: "foo", value: "bar" },
{ key: "bob", value: "oldbob", secret: false },
{ key: "foo", value: "bar", secret: false },
],
}
)()
@@ -52,8 +52,8 @@ describe("runPreRequestScript", () => {
{
global: [],
selected: [
{ key: "bob", value: "oldbob" },
{ key: "foo", value: "bar" },
{ key: "bob", value: "oldbob", secret: false },
{ key: "foo", value: "bar", secret: false },
],
}
)()
@@ -69,8 +69,8 @@ describe("runPreRequestScript", () => {
{
global: [],
selected: [
{ key: "bob", value: "oldbob" },
{ key: "foo", value: "bar" },
{ key: "bob", value: "oldbob", secret: false },
{ key: "foo", value: "bar", secret: false },
],
}
)()
@@ -87,7 +87,7 @@ describe("runPreRequestScript", () => {
)()
).resolves.toEqualRight({
global: [],
selected: [{ key: "foo", value: "bar" }],
selected: [{ key: "foo", value: "bar", secret: false }],
})
})
})

View File

@@ -10,13 +10,13 @@ describe("Base64 helper functions", () => {
atob: {
script: `pw.env.set("atob", atob("SGVsbG8gV29ybGQ="))`,
environment: {
selected: [{ key: "atob", value: "Hello World" }],
selected: [{ key: "atob", value: "Hello World", secret: false }],
},
},
btoa: {
script: `pw.env.set("btoa", btoa("Hello World"))`,
environment: {
selected: [{ key: "btoa", value: "SGVsbG8gV29ybGQ=" }],
selected: [{ key: "btoa", value: "SGVsbG8gV29ybGQ=", secret: false }],
},
},
}

View File

@@ -31,6 +31,7 @@ describe("pw.env.get", () => {
{
key: "a",
value: "b",
secret: false,
},
],
}
@@ -59,6 +60,7 @@ describe("pw.env.get", () => {
{
key: "a",
value: "b",
secret: false,
},
],
selected: [],
@@ -112,12 +114,14 @@ describe("pw.env.get", () => {
{
key: "a",
value: "global val",
secret: false,
},
],
selected: [
{
key: "a",
value: "selected val",
secret: false,
},
],
}
@@ -147,6 +151,7 @@ describe("pw.env.get", () => {
{
key: "a",
value: "<<hello>>",
secret: false,
},
],
}

View File

@@ -31,6 +31,7 @@ describe("pw.env.getResolve", () => {
{
key: "a",
value: "b",
secret: false,
},
],
}
@@ -59,6 +60,7 @@ describe("pw.env.getResolve", () => {
{
key: "a",
value: "b",
secret: false,
},
],
selected: [],
@@ -112,12 +114,14 @@ describe("pw.env.getResolve", () => {
{
key: "a",
value: "global val",
secret: false,
},
],
selected: [
{
key: "a",
value: "selected val",
secret: false,
},
],
}
@@ -147,10 +151,12 @@ describe("pw.env.getResolve", () => {
{
key: "a",
value: "<<hello>>",
secret: false,
},
{
key: "hello",
value: "there",
secret: false,
},
],
}
@@ -180,10 +186,12 @@ describe("pw.env.getResolve", () => {
{
key: "a",
value: "<<hello>>",
secret: false,
},
{
key: "hello",
value: "<<a>>",
secret: false,
},
],
}

View File

@@ -43,6 +43,7 @@ describe("pw.env.resolve", () => {
{
key: "hello",
value: "there",
secret: false,
},
],
selected: [],
@@ -73,6 +74,7 @@ describe("pw.env.resolve", () => {
{
key: "hello",
value: "there",
secret: false,
},
],
}
@@ -101,12 +103,14 @@ describe("pw.env.resolve", () => {
{
key: "hello",
value: "yo",
secret: false,
},
],
selected: [
{
key: "hello",
value: "there",
secret: false,
},
],
}
@@ -136,10 +140,12 @@ describe("pw.env.resolve", () => {
{
key: "hello",
value: "<<there>>",
secret: false,
},
{
key: "there",
value: "<<hello>>",
secret: false,
},
],
}

View File

@@ -35,6 +35,7 @@ describe("pw.env.set", () => {
{
key: "a",
value: "b",
secret: false,
},
],
}
@@ -45,6 +46,7 @@ describe("pw.env.set", () => {
{
key: "a",
value: "c",
secret: false,
},
],
})
@@ -62,6 +64,7 @@ describe("pw.env.set", () => {
{
key: "a",
value: "b",
secret: false,
},
],
selected: [],
@@ -73,6 +76,7 @@ describe("pw.env.set", () => {
{
key: "a",
value: "c",
secret: false,
},
],
})
@@ -90,12 +94,14 @@ describe("pw.env.set", () => {
{
key: "a",
value: "b",
secret: false,
},
],
selected: [
{
key: "a",
value: "d",
secret: false,
},
],
}
@@ -106,12 +112,14 @@ describe("pw.env.set", () => {
{
key: "a",
value: "b",
secret: false,
},
],
selected: [
{
key: "a",
value: "c",
secret: false,
},
],
})
@@ -136,6 +144,7 @@ describe("pw.env.set", () => {
{
key: "a",
value: "c",
secret: false,
},
],
})

View File

@@ -35,6 +35,7 @@ describe("pw.env.unset", () => {
{
key: "baseUrl",
value: "https://echo.hoppscotch.io",
secret: false,
},
],
}
@@ -57,6 +58,7 @@ describe("pw.env.unset", () => {
{
key: "baseUrl",
value: "https://echo.hoppscotch.io",
secret: false,
},
],
selected: [],
@@ -80,12 +82,14 @@ describe("pw.env.unset", () => {
{
key: "baseUrl",
value: "https://httpbin.org",
secret: false,
},
],
selected: [
{
key: "baseUrl",
value: "https://echo.hoppscotch.io",
secret: false,
},
],
}
@@ -96,6 +100,7 @@ describe("pw.env.unset", () => {
{
key: "baseUrl",
value: "https://httpbin.org",
secret: false,
},
],
selected: [],
@@ -114,16 +119,19 @@ describe("pw.env.unset", () => {
{
key: "baseUrl",
value: "https://echo.hoppscotch.io",
secret: false,
},
],
selected: [
{
key: "baseUrl",
value: "https://httpbin.org",
secret: false,
},
{
key: "baseUrl",
value: "https://echo.hoppscotch.io",
secret: false,
},
],
}
@@ -134,12 +142,14 @@ describe("pw.env.unset", () => {
{
key: "baseUrl",
value: "https://echo.hoppscotch.io",
secret: false,
},
],
selected: [
{
key: "baseUrl",
value: "https://echo.hoppscotch.io",
secret: false,
},
],
})
@@ -157,10 +167,12 @@ describe("pw.env.unset", () => {
{
key: "baseUrl",
value: "https://httpbin.org/",
secret: false,
},
{
key: "baseUrl",
value: "https://echo.hoppscotch.io",
secret: false,
},
],
selected: [],
@@ -172,6 +184,7 @@ describe("pw.env.unset", () => {
{
key: "baseUrl",
value: "https://echo.hoppscotch.io",
secret: false,
},
],
selected: [],
@@ -225,6 +238,7 @@ describe("pw.env.unset", () => {
{
key: "baseUrl",
value: "https://echo.hoppscotch.io",
secret: false,
},
],
}

View File

@@ -1,5 +1,3 @@
import { Environment } from "@hoppscotch/data"
/**
* The response object structure exposed to the test script
*/
@@ -43,6 +41,13 @@ export type TestDescriptor = {
children: TestDescriptor[]
}
// Representation of a transformed state for environment variables in the sandbox
type TransformedEnvironmentVariable = {
key: string
value: string
secret: boolean
}
/**
* Defines the result of a test script execution
*/
@@ -50,8 +55,8 @@ export type TestDescriptor = {
export type TestResult = {
tests: TestDescriptor[]
envs: {
global: Environment["variables"]
selected: Environment["variables"]
global: TransformedEnvironmentVariable[]
selected: TransformedEnvironmentVariable[]
}
}

View File

@@ -38,13 +38,20 @@ const setEnv = (
const indexInGlobal = findEnvIndex(envName, global)
if (indexInSelected >= 0) {
selected[indexInSelected].value = envValue
const selectedEnv = selected[indexInSelected]
if ("value" in selectedEnv) {
selectedEnv.value = envValue
}
} else if (indexInGlobal >= 0) {
global[indexInGlobal].value = envValue
if ("value" in global[indexInGlobal]) {
// eslint-disable-next-line @typescript-eslint/no-extra-semi
;(global[indexInGlobal] as { value: string }).value = envValue
}
} else {
selected.push({
key: envName,
value: envValue,
secret: false,
})
}
@@ -86,9 +93,9 @@ const getSharedMethods = (envs: TestResult["envs"]) => {
const result = pipe(
getEnv(key, updatedEnvs),
O.match(
O.fold(
() => undefined,
({ value }) => String(value)
(env) => String(env.value)
)
)
@@ -104,14 +111,13 @@ const getSharedMethods = (envs: TestResult["envs"]) => {
getEnv(key, updatedEnvs),
E.fromOption(() => "INVALID_KEY" as const),
E.map(({ value }) =>
E.map((e) =>
pipe(
parseTemplateStringE(value, [
parseTemplateStringE(e.value, [
...updatedEnvs.selected,
...updatedEnvs.global,
]),
// If the recursive resolution failed, return the unresolved value
E.getOrElse(() => value)
]), // If the recursive resolution failed, return the unresolved value
E.getOrElse(() => e.value)
)
),
E.map((x) => String(x)),

View File

@@ -81,6 +81,7 @@ async function loadUserEnvironments() {
runDispatchWithOutSyncing(() => {
replaceEnvironments(
environments.map(({ id, variables, name }) => ({
v: 1,
id,
name,
variables: JSON.parse(variables),
@@ -164,6 +165,7 @@ function setupUserEnvironmentUpdatedSubscription() {
if ((localIndex || localIndex == 0) && name) {
runDispatchWithOutSyncing(() => {
updateEnvironment(localIndex, {
v: 1,
id,
name,
variables: JSON.parse(variables),

View File

@@ -20,10 +20,14 @@ import {
deleteUserEnvironment,
updateUserEnvironment,
} from "./environments.api"
import { SecretEnvironmentService } from "@hoppscotch/common/services/secret-environment.service"
import { getService } from "@hoppscotch/common/modules/dioc"
export const environmentsMapper = createMapper<number, string>()
export const globalEnvironmentMapper = createMapper<number, string>()
const secretEnvironmentService = getService(SecretEnvironmentService)
export const storeSyncDefinition: StoreSyncDefinitionOf<
typeof environmentsStore
> = {
@@ -34,6 +38,12 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
if (E.isRight(res)) {
const id = res.right.createUserEnvironment.id
secretEnvironmentService.updateSecretEnvironmentID(
environmentsStore.value.environments[lastCreatedEnvIndex].id,
id
)
environmentsStore.value.environments[lastCreatedEnvIndex].id = id
removeDuplicateEntry(id)
}
@@ -84,7 +94,6 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
},
updateEnvironment({ envIndex, updatedEnv }) {
const backendId = environmentsStore.value.environments[envIndex].id
if (backendId) {
updateUserEnvironment(backendId, updatedEnv)()
}
@@ -97,7 +106,12 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
setGlobalVariables({ entries }) {
const backendId = getGlobalVariableID()
if (backendId) {
updateUserEnvironment(backendId, { name: "", variables: entries })()
updateUserEnvironment(backendId, {
name: "",
variables: entries,
id: "",
v: 1,
})()
}
},
clearGlobalVariables() {