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

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