feat: secret variables in environments (#3779)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
)()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user