Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow env var values to be hidden in UI #5043

Merged
merged 27 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1a9d6e2
fix removal of unsaved vars
cstns Jan 17, 2025
3c3c4bf
add fe backwards compat for hidden field and the ability to toggle vi…
cstns Jan 20, 2025
d9e887b
removed commented code
cstns Jan 20, 2025
2e7f36b
hide values for env vars marked as hidden
cstns Jan 20, 2025
a40fd06
Merge branch 'refs/heads/main' into 4835_set-env-vars-to-be-hidden-af…
cstns Jan 20, 2025
f0dbb10
fix validation recursion and prevent existing variables to be renamed…
cstns Jan 20, 2025
3fcb4e8
fix validation recursion and prevent existing variables to be renamed…
cstns Jan 20, 2025
5b7a94c
fix new var glitch right after update caused by stale data
cstns Jan 20, 2025
a8c730c
Merge remote-tracking branch 'origin/4835_set-env-vars-to-be-hidden-a…
cstns Jan 20, 2025
3bddd82
pass originalEnvVars from parent component to better handle visibilit…
cstns Jan 20, 2025
8468ac0
extend var name validator to include an error message for var names t…
cstns Jan 20, 2025
4100863
import envVar hidden key when cloning instances
cstns Jan 20, 2025
ac53c4e
hide hidden env values from the ui
cstns Jan 20, 2025
4ddf24e
prevent the device env form to be submitted if errors are present
cstns Jan 20, 2025
014494a
fix 500 when updating hidden env values when no env updates
cstns Jan 21, 2025
7b36e6a
add unit tests
cstns Jan 21, 2025
71739f2
disable name field for hidden env vars
cstns Jan 21, 2025
dbb49d0
fix alert close button when multi-line message
cstns Jan 21, 2025
8c75d60
add explicit selectors for e2e tests and instance e2e tests
cstns Jan 21, 2025
da1a04c
disable input name only for hidden saved vars
cstns Jan 21, 2025
ba36620
fix device env update not clearing initial original values
cstns Jan 21, 2025
8e16add
add device e2e tests
cstns Jan 21, 2025
a39faaa
Merge branch 'main' into 4835_set-env-vars-to-be-hidden-after-creation
cstns Jan 21, 2025
4ff1091
remove filtering of hidden device env var from the device controller …
cstns Jan 22, 2025
1d490f0
remove filtering of hidden instance env var from the device controller
cstns Jan 22, 2025
7321a95
fix failing test
cstns Jan 22, 2025
9d8b069
add tooltips on env var visibility selector
cstns Jan 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions forge/db/controllers/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ module.exports = {
result.push(makeVar('FF_SNAPSHOT_ID', snapshotId))
result.push(makeVar('FF_SNAPSHOT_NAME', snapshotName))
result.push(...app.db.controllers.Device.removePlatformSpecificEnvVars(envVars))

return result
},

Expand Down
4 changes: 3 additions & 1 deletion forge/db/controllers/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ module.exports = {
result.push(makeVar('FF_PROJECT_ID', project.id || '', true)) // deprecated as of V1.6.0
result.push(makeVar('FF_PROJECT_NAME', project.name || '', true)) // deprecated as of V1.6.0
result.push(...app.db.controllers.Project.removePlatformSpecificEnvVars(envVars))

return result
},

Expand Down Expand Up @@ -485,7 +486,8 @@ module.exports = {
sourceProjectEnvVars.forEach(envVar => {
newProjectSettings.env.push({
name: envVar.name,
value: options.envVars === 'keys' ? '' : envVar.value
value: options.envVars === 'keys' ? '' : envVar.value,
hidden: envVar.hidden ?? false
})
})
}
Expand Down
7 changes: 6 additions & 1 deletion forge/db/views/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,12 @@ module.exports = function (app) {
result.launcherSettings.disableAutoSafeMode = disableAutoSafeMode.value
}
// Environment
result.settings.env = app.db.controllers.Project.insertPlatformSpecificEnvVars(proj, result.settings.env)
result.settings.env = app.db.controllers.Project.insertPlatformSpecificEnvVars(proj, result.settings.env).map((env) => {
if (Object.hasOwnProperty.call(env, 'hidden') && env.hidden === true) {
env.value = ''
}
return env
})
if (!result.settings.palette?.modules) {
// If there are no modules listed in settings, check the StorageSettings
// for the project to see what Node-RED may already think is installed
Expand Down
6 changes: 6 additions & 0 deletions forge/routes/api/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,12 @@ module.exports = async function (app) {
}
}, async (request, reply) => {
const settings = await request.device.getAllSettings()
settings.env = settings.env.map((env) => {
if (Object.hasOwnProperty.call(env, 'hidden') && env.hidden === true) {
env.value = ''
}
return env
})
if (request.teamMembership?.role === Roles.Owner) {
reply.send(settings)
} else {
Expand Down
11 changes: 11 additions & 0 deletions forge/routes/api/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,17 @@ module.exports = async function (app) {

// Merge the settings into the existing values
const currentProjectSettings = await request.project.getSetting(KEY_SETTINGS) || {}
if (newSettings.env && Array.isArray(newSettings.env)) {
newSettings.env = newSettings.env.map(env => {
// hidden env vars are received as empty strings so we'll replace the empty string with the previous value,
// allowing them to be overwritten when needed
if (Object.prototype.hasOwnProperty.call(env, 'hidden') && env.hidden && !env.value.length) {
const previousValue = currentProjectSettings.env.find(e => e.name === env.name)
env.value = previousValue.value
}
return env
})
}
const updatedSettings = app.db.controllers.ProjectTemplate.mergeSettings(currentProjectSettings, newSettings)

changesToPersist.settings = { from: currentProjectSettings, to: updatedSettings }
Expand Down
171 changes: 121 additions & 50 deletions frontend/src/pages/admin/Template/sections/Environment.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<form class="space-y-4" @submit.prevent>
<form class="space-y-4 ff-environment" @submit.prevent>
<FormHeading>
<div class="flex">
<div class="mr-4">Environment Variables</div>
Expand Down Expand Up @@ -27,6 +27,7 @@
search-placeholder="Search environment variables..."
:columns="editTemplate ? [,,,,] : [,,,]"
:noDataMessage="noDataMessage"
data-el="env-vars-table"
>
<template #actions>
<template v-if="!readOnly">
Expand All @@ -35,7 +36,7 @@
<template #icon><DocumentDownloadIcon /></template>
<span class="hidden sm:flex pl-1">Import .env</span>
</ff-button>
<ff-button kind="primary" accesskey="a" @click="addVarHandler">
<ff-button kind="primary" accesskey="a" data-el="add-variable" @click="addVarHandler">
<template #icon><PlusSmIcon /></template>
<span class="hidden sm:flex pl-1">Add variable</span>
</ff-button>
Expand All @@ -50,31 +51,39 @@
</ff-data-table-row>
</template>
<template #rows>
<ff-data-table-row v-for="(item) in filteredRows" :key="item.index">
<ff-data-table-row v-for="(item) in filteredRows" :key="item.index" :data-row="'row-' + item.name">
<td class="ff-data-table--cell !pl-1 !pr-0 !py-1 border min-w-max max-w-sm align-top">
<FormRow
v-model="item.name"
v-ff-tooltip:left="'Cannot be renamed'"
class="font-mono"
:containerClass="'w-full' + (!readOnly && (editTemplate || item.policy === undefined)) ? ' env-cell-uneditable':''"
:inputClass="item.deprecated ? 'w-full text-yellow-700 italic' : 'w-full'"
:error="item.error"
:disabled="item.encrypted"
:error="errors[item.index].error"
:disabled="isDisabledName(item)"
value-empty-text=""
data-el="var-name"
:type="(!readOnly && (editTemplate || item.policy === undefined))?'text':'uneditable'"
/>
</td>
<td class="ff-data-table--cell !p-1 border w-3/5 align-top max-w-xl" :class="{'align-middle':item.encrypted}">
<div v-if="!item.encrypted" class="w-full">
<template v-if="(!readOnly && (editTemplate || item.policy === undefined || item.policy))">
<!-- editable -->
<textarea v-model="item.value" :class="'w-full font-mono max-h-40' + ((item.value && item.value.split('\n').length > 1) ? ' h-20' : ' h-8') + (item.deprecated ? ' text-yellow-700 italic' : '')" />
<textarea
v-model="item.value"
:placeholder="item.hidden ? 'Value hidden' : ''"
data-el="var-value"
:class="'w-full font-mono max-h-40' + ((item.value && item.value.split('\n').length > 1) ? ' h-20' : ' h-8') + (item.deprecated ? ' text-yellow-700 italic' : '')"
/>
</template>
<template v-else>
<FormRow
v-model="item.value"
class="font-mono"
containerClass="w-full env-cell-uneditable"
:inputClass="item.deprecated ? 'text-yellow-700 italic' : ''"
data-el="var-value"
value-empty-text=""
:type="'uneditable'"
/>
Expand All @@ -83,12 +92,48 @@
<div v-else class="pt-1 text-gray-400"><LockClosedIcon class="inline w-4" /> encrypted</div>
</td>
<td class="ff-data-table--cell !p-1 border w-16 align-top">
<div v-if="(!readOnly && (editTemplate|| item.policy === undefined))" class="flex justify-center mt-1">
<ff-button kind="tertiary" size="small" @click="removeEnv(item.index)">
<div v-if="(!readOnly && (editTemplate|| item.policy === undefined))" class="flex justify-center mt-1 items-center gap-3">
<ff-button kind="tertiary" size="small" data-el="remove" @click="removeEnv(item.index)">
<template #icon>
<TrashIcon />
</template>
</ff-button>
<template v-if="typeof item.index === 'string' && item.index.startsWith('add-')">
<ff-button
v-ff-tooltip:left="'Setting visibility to hidden will lock the variable name and make the value hidden. To revert, you\'ll need to delete and recreate the variable.'"
kind="tertiary"
size="small"
data-el="visibility"
@click="setEnvHidden(item.index)"
>
<template #icon>
<EyeOffIcon v-if="item.hidden" />
<EyeIcon v-else />
</template>
</ff-button>
</template>
<template v-else>
<span
v-if="!!(originalEnvVars.find(v => v.index === item.index))?.hidden"
:key="item.index"
v-ff-tooltip:left="'Cannot be made public again, only overwritten'"
class="mx-2 disabled"
data-el="visibility"
>
<EyeOffIcon class="ff-icon-sm color-grey" />
</span>
<ff-button
v-else
v-ff-tooltip:left="'Setting visibility to hidden will lock the variable name and make the value hidden. To revert, you\'ll need to delete and recreate the variable.'"
kind="tertiary" size="small" data-el="visibility"
@click="setEnvHidden(item.index)"
>
<template #icon>
<EyeOffIcon v-if="item.hidden" />
<EyeIcon v-else />
</template>
</ff-button>
</template>
</div>
<div
v-else-if="(item.deprecated === true)"
Expand All @@ -112,7 +157,7 @@
</template>

<script>
import { DocumentDownloadIcon, ExclamationIcon, InformationCircleIcon, LockClosedIcon, PlusSmIcon, TrashIcon } from '@heroicons/vue/outline'
import { DocumentDownloadIcon, ExclamationIcon, EyeIcon, EyeOffIcon, InformationCircleIcon, LockClosedIcon, PlusSmIcon, TrashIcon } from '@heroicons/vue/outline'

import FormHeading from '../../../../components/FormHeading.vue'
import FormRow from '../../../../components/FormRow.vue'
Expand All @@ -133,7 +178,9 @@ export default {
PlusSmIcon,
LockClosedIcon,
ExclamationIcon,
InformationCircleIcon
InformationCircleIcon,
EyeIcon,
EyeOffIcon
},
props: {
editTemplate: {
Expand All @@ -152,17 +199,21 @@ export default {
// for the dialog that opens, e.g. "FlowFuse - Device Group Environment Variables"
type: String,
default: null
},
originalEnvVars: {
type: Array,
required: true
}
},
emits: ['update:modelValue'],
emits: ['update:modelValue', 'validated'],
data () {
return {
addedCount: 0,
input: {},
envVarLookup: {},
originalEnvVars: null,
search: '',
pauseEnvWatch: false
pauseEnvWatch: false,
errors: { }
}
},
computed: {
Expand Down Expand Up @@ -192,23 +243,7 @@ export default {
},
'editable.settings.env': {
deep: true,
handler (v) {
// only validate if the env data has changed (ignore the error
// field as that is updated in the validate method & would cause
// an infinite loop)
const _v = v.map(row => {
const { error, ...rest } = row
return rest
})
const _oldV = (this.originalEnvVars || []).map(row => {
const { error, ...rest } = row
return rest
})
if (JSON.stringify(_v) === JSON.stringify(_oldV)) {
return // no changes to validate
}
this.validate()
}
handler: 'validate'
}
},
mounted () {
Expand All @@ -224,38 +259,41 @@ export default {
this.updateLookup()
const counts = {}

// if this.originalEnvVars = null, then this is the first time here
// so we need to make a copy of the original env vars for later comparison
if (this.originalEnvVars === null) {
this.originalEnvVars = JSON.parse(JSON.stringify(this.editable?.settings?.env || [])) // make a copy for later comparison
}

// first scan: sanitise the policy field & index, clear errors, count up duplicate names
envVars.forEach((field, i) => {
// set field policy
if (field.policy === undefined && (field.platform === true || field.deprecated === true)) {
field.policy = false
}
// set field index if not present
if (typeof field.index === 'undefined' || (typeof field.index === 'number' && field.index !== i)) {
field.index = i
}
counts[field.name] = (counts[field.name] || 0) + 1 // count the number of times each name appears
field.error = '' // clear any error

// count the number of times each name appears
counts[field.name] = (counts[field.name] || 0) + 1

// clear existing errors
this.errors[field.index] = { error: '' }
})

let hasErrors = false
// second scan: check for errors & flag them
envVars.forEach((field, i) => {
const newRow = typeof field.index === 'string' && field.index.startsWith('add-')
if (field.name === '' || / /.test(field.name)) {
field.error = 'Invalid name' // if name is empty are empty - or - name contains spaces
} else if (newRow && field.name.startsWith('FF_')) {
field.error = 'Reserved name'
this.errors[field.index].error = 'Invalid name' // if name is empty are empty - or - name contains spaces
hasErrors = true
} else if (field.name.startsWith('FF_') && !field.platform) {
this.errors[field.index].error = 'Reserved name'
hasErrors = true
} else if (!field.name.match(/^[a-zA-Z_]+[a-zA-Z0-9_]*$/)) {
this.errors[field.index].error = 'Names must start with a character'
hasErrors = true
} else if (counts[field.name] > 1) {
field.error = 'Duplicate name'
this.errors[field.index].error = 'Duplicate name'
hasErrors = true
}
})

// lastly, update originalEnvVars
this.originalEnvVars = JSON.parse(JSON.stringify(this.editable?.settings?.env || [])) // for later comparison
this.$emit('validated', hasErrors)
},
updateLookup () {
this.envVarLookup = {}
Expand All @@ -274,6 +312,7 @@ export default {
name: key || '',
value: value || '',
encrypted: encrypt,
hidden: false,
policy: this.editTemplate ? false : undefined
}
this.envVarLookup[field.name] = this.editable.settings.env.length
Expand Down Expand Up @@ -302,8 +341,13 @@ export default {
}
},
removeEnv (index) {
const field = this.editable.settings.env[index]
delete this.envVarLookup[field.name]
if (typeof index === 'string' && index.startsWith('add-')) {
index = this.editable.settings.env.findIndex(env => env.index === index)
} else {
const field = this.editable.settings.env[index]
delete this.envVarLookup[field.name]
}

this.editable.settings.env.splice(index, 1)
},
importEnv () {
Expand Down Expand Up @@ -337,12 +381,29 @@ export default {
fileUpload.value = ''
}
fileUpload.click()
},
setEnvHidden (index) {
let field
const isUnsavedVar = typeof index === 'string' && index.startsWith('add-')

if (isUnsavedVar) {
field = this.editable.settings.env.find(env => env.index === index)
} else {
field = this.editable.settings.env[index]
}

field.hidden = !field.hidden
},
isDisabledName (item) {
if (item.encrypted) return true
const originalItem = this.originalEnvVars.find(env => env.index === item.index)
return typeof item.index === 'number' && item.hidden && originalItem?.hidden
}
}
}
</script>

<style scoped>
<style lang="scss" scoped>
.ff-data-table--cell textarea {
resize: vertical;
max-height: 10rem; /* 160px approx ~8 lines, after which user will need to scroll */
Expand Down Expand Up @@ -372,3 +433,13 @@ export default {
cursor: default;
}
</style>

<style lang="scss">
.ff-environment {
.ff-input.ff-text-input {
input:disabled {
color: $ff-grey-600
}
}
}
</style>
Loading
Loading