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>