3
0

Add support for injecting custom code-snippets in webapps

This commit is contained in:
Mumbi Francis 2024-08-08 16:42:18 +03:00 committed by Mumbi Francis
parent f5e6c45d07
commit 2e8b0e96a3
25 changed files with 489 additions and 3 deletions

View File

@ -123,7 +123,11 @@ module.exports = ({ appFlavour, appLabel, version = process.env.BUILD_VERSION, t
hot: false,
proxy: {
'^/custom.css': {
'^/custom.css': {
target: fetchBaseUrl(),
},
'^/code-snippets.js': {
target: fetchBaseUrl(),
},
},

View File

@ -28,5 +28,8 @@
<div id="app"></div>
<!-- built files will be auto injected -->
<script src="<%= BASE_URL %>config.js" type="text/javascript"></script>
<!-- contains cdns that will be injected at the head of the page -->
<script src="<%= BASE_URL %>code-snippets.js" type="text/javascript"></script>
</body>
</html>

View File

@ -120,6 +120,12 @@ export default {
icon: 'cloud',
can: ['system/', 'dal-connections.search'],
},
{
label: 'system.items.code-snippets',
route: 'system.codesnippets',
icon: 'file-code',
can: ['system/', 'settings.read'],
},
{
label: 'system.items.sensitivityLevel',
route: 'system.sensitivityLevel',

View File

@ -0,0 +1,297 @@
<template>
<b-container
class="pt-2 pb-3"
>
<c-content-header
:title="$t('title')"
/>
<b-card
body-class="p-0"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<div class="align-items-center gap-1 p-3">
<b-button
variant="primary"
size="lg"
@click="newCodeSnippet()"
>
{{ $t('code-snippets.add') }}
</b-button>
</div>
<b-table
:items="providerItems"
:fields="codeSnippetProviderFields"
head-variant="light"
show-empty
hover
class="mb-0"
style="min-height: 50px;"
>
<template #cell(provider)="{ item }">
{{ item.provider || item.tag }}
</template>
<template #empty>
<p
data-test-id="no-matches"
class="text-center text-dark"
style="margin-top: 1vh;"
>
{{ $t('code-snippets.empty') }}
</p>
</template>
<template #cell(editor)="{ item }">
<c-input-confirm
v-if="item.delete"
:icon="item.deleted ? ['fas', 'trash-restore'] : undefined"
@confirmed="item.delete()"
/>
<b-button
variant="link"
@click="openEditor(item.editor)"
>
<font-awesome-icon
:icon="['fas', 'wrench']"
/>
</b-button>
</template>
</b-table>
<b-modal
id="modal-codeSnippet"
v-model="modal.open"
:title="modal.title"
scrollable
size="lg"
title-class="text-capitalize"
@ok="modal.updater(modal.data)"
>
<b-form-group
:label="$t('code-snippets.form.provider.label')"
label-class="text-primary"
>
<b-input-group>
<b-form-input v-model="modal.data.name" />
</b-input-group>
</b-form-group>
<div>
<div class="mb-2">
<h5>
{{ $t('code-snippets.add') }}
</h5>
<span class="text-muted">
{{ $t('code-snippets.form.value.description') }}
</span>
</div>
<c-ace-editor
v-model="modal.data.script"
lang="javascript"
height="500px"
font-size="14px"
show-line-numbers
:border="false"
:show-popout="false"
/>
</div>
<template #modal-footer="{ ok, cancel}">
<c-input-confirm
size="md"
variant="danger"
@confirmed="deleteCodeSnippet(modal.index)"
>
{{ $t('general:label.delete') }}
</c-input-confirm>
<b-button
variant="light"
class="ml-auto"
@click="cancel()"
>
{{ $t('general:label.cancel') }}
</b-button>
<b-button
variant="primary"
@click="ok()"
>
{{ $t('general:label.saveAndClose') }}
</b-button>
</template>
</b-modal>
<template #footer>
<c-button-submit
:disabled="!canManage"
:processing="codeSnippet.processing"
:success="codeSnippet.success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="onSubmit()"
/>
</template>
</b-card>
</b-container>
</template>
<script>
import editorHelpers from 'corteza-webapp-admin/src/mixins/editorHelpers'
import { components } from '@cortezaproject/corteza-vue'
import { mapGetters } from 'vuex'
const { CAceEditor } = components
export default {
name: 'CSystemCodeSnippetEditor',
i18nOptions: {
namespaces: 'system.code-snippets',
keyPrefix: 'editor',
},
components: {
CAceEditor,
},
mixins: [
editorHelpers,
],
data () {
return {
codeSnippets: [],
modal: {
open: false,
editor: null,
title: null,
data: [],
index: null,
},
codeSnippet: {
processing: false,
success: false,
},
originalCodeSnippets: [],
}
},
computed: {
...mapGetters({
canManage: 'rbac/can',
}),
codeSnippetProviderFields () {
return [
{ key: 'provider', label: this.$t('code-snippets.table-headers.provider'), thStyle: { width: '200px' }, tdClass: 'text-capitalize' },
{ key: 'value', label: this.$t('code-snippets.table-headers.value'), tdClass: 'td-content-overflow' },
{ key: 'editor', label: '', thStyle: { width: '200px' }, tdClass: 'text-right' },
]
},
providerItems () {
return this.codeSnippets.map((s, i) => ({
provider: s.name,
value: s.script,
editor: {
data: s,
index: i,
title: s.name,
updater: (changed) => {
this.codeSnippets[i] = changed
},
},
}))
},
},
created () {
this.fetchSettings()
this.originalCodeSnippets = [...this.codeSnippets]
},
methods: {
openEditor ({ component, title, data, updater }) {
this.modal.open = true
this.modal.component = component
this.modal.title = title
this.modal.updater = updater
// deref
this.modal.data = data
},
newCodeSnippet () {
this.openEditor({
title: this.$t('code-snippets.add'),
data: {
name: '',
script: '<' + 'script> ' + '</' + 'script>',
},
updater: (changed) => {
this.codeSnippets.push(changed)
},
})
},
fetchSettings () {
this.incLoader()
this.$Settings.fetch()
return this.$SystemAPI.settingsList({ prefix: 'code-snippets' })
.then(settings => {
if (settings && settings[0]) {
this.codeSnippets = settings[0].value
} else {
this.codeSnippets = []
}
})
.catch(this.toastErrorHandler(this.$t('notification:settings.codeSnippet.fetch.error')))
.finally(() => {
this.decLoader()
})
},
settingsUpdate (action) {
this.codeSnippet.processing = true
this.$SystemAPI.settingsUpdate({ values: [{ name: 'code-snippets', value: this.codeSnippets }] })
.then(() => {
this.animateSuccess('codeSnippet')
if (action === 'delete') {
this.toastSuccess(this.$t('notification:settings.codeSnippet.delete.success'))
} else {
this.toastSuccess(this.$t('notification:settings.codeSnippet.update.success'))
}
})
.catch(this.toastErrorHandler(this.$t('notification:settings.codeSnippet.update.error')))
.finally(() => {
this.codeSnippet.processing = false
})
},
onSubmit () {
this.settingsUpdate('update')
},
deleteCodeSnippet (i) {
this.codeSnippets.splice(i, 1)
this.settingsUpdate('delete')
this.$bvModal.hide('modal-codeSnippet')
},
},
}
</script>
<style>
.td-content-overflow {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -68,6 +68,7 @@ export default {
processing: false,
success: false,
},
}
},

View File

@ -110,6 +110,8 @@ export default [
r('system.connection.new', 'connection/new', 'System/Connection/Editor'),
r('system.connection.edit', 'connection/edit/:connectionID', 'System/Connection/Editor'),
r('system.codesnippets', 'codesnippets', 'System/CodeSnippets/Index'),
combo('system', 'sensitivityLevel'),
combo('system', 'queue', { pkey: 'queueID' }),

View File

@ -124,6 +124,10 @@ module.exports = ({ appFlavour, appLabel, version = process.env.BUILD_VERSION, t
'^/custom.css': {
target: fetchBaseUrl(),
},
'^/code-snippets.js': {
target: fetchBaseUrl(),
},
},
watchOptions: {

View File

@ -28,5 +28,8 @@
<div id="app"></div>
<!-- built files will be auto injected -->
<script src="<%= BASE_URL %>config.js" type="text/javascript"></script>
<!-- contains cdns that will be injected at the head of the page -->
<script src="<%= BASE_URL %>code-snippets.js" type="text/javascript"></script>
</body>
</html>

View File

@ -124,6 +124,10 @@ module.exports = ({ appFlavour, appLabel, version = process.env.BUILD_VERSION, t
'^/custom.css': {
target: fetchBaseUrl(),
},
'^/code-snippets.js': {
target: fetchBaseUrl(),
},
},
watchOptions: {

View File

@ -27,5 +27,8 @@
<div id="app"></div>
<!-- built files will be auto injected -->
<script src="<%= BASE_URL %>config.js" type="text/javascript"></script>
<!-- contains cdns that will be injected at the head of the page -->
<script src="<%= BASE_URL %>code-snippets.js" type="text/javascript"></script>
</body>
</html>

View File

@ -124,6 +124,10 @@ module.exports = ({ appFlavour, appLabel, version = process.env.BUILD_VERSION, t
'^/custom.css': {
target: fetchBaseUrl(),
},
'^/code-snippets.js': {
target: fetchBaseUrl(),
},
},
watchOptions: {

View File

@ -28,5 +28,8 @@
<div id="app"></div>
<!-- built files will be auto injected -->
<script src="<%= BASE_URL %>config.js" type="text/javascript"></script>
<!-- contains cdns that will be injected at the head of the page -->
<script src="<%= BASE_URL %>code-snippets.js" type="text/javascript"></script>
</body>
</html>

View File

@ -124,6 +124,10 @@ module.exports = ({ appFlavour, appLabel, version = process.env.BUILD_VERSION, t
'^/custom.css': {
target: fetchBaseUrl(),
},
'^/code-snippets.js': {
target: fetchBaseUrl(),
},
},
watchOptions: {

View File

@ -27,5 +27,8 @@
<div id="app"></div>
<!-- built files will be auto injected -->
<script src="<%= BASE_URL %>config.js" type="text/javascript"></script>
<!-- contains cdns that will be injected at the head of the page -->
<script src="<%= BASE_URL %>code-snippets.js" type="text/javascript"></script>
</body>
</html>

View File

@ -124,6 +124,10 @@ module.exports = ({ appFlavour, appLabel, version = process.env.BUILD_VERSION, t
'^/custom.css': {
target: fetchBaseUrl(),
},
'^/code-snippets.js': {
target: fetchBaseUrl(),
},
},
watchOptions: {

View File

@ -27,5 +27,8 @@
<div id="app"></div>
<!-- built files will be auto injected -->
<script src="<%= BASE_URL %>config.js" type="text/javascript"></script>
<!-- contains cdns that will be injected at the head of the page -->
<script src="<%= BASE_URL %>code-snippets.js" type="text/javascript"></script>
</body>
</html>

View File

@ -124,6 +124,10 @@ module.exports = ({ appFlavour, appLabel, version = process.env.BUILD_VERSION, t
'^/custom.css': {
target: fetchBaseUrl(),
},
'^/code-snippets.js': {
target: fetchBaseUrl(),
},
},
watchOptions: {

View File

@ -37,5 +37,8 @@
<div id="app"></div>
<!-- built files will be auto injected -->
<script src="<%= BASE_URL %>config.js" type="text/javascript"></script>
<!-- contains cdns that will be injected at the head of the page -->
<script src="<%= BASE_URL %>code-snippets.js" type="text/javascript"></script>
</body>
</html>

View File

@ -124,6 +124,10 @@ module.exports = ({ appFlavour, appLabel, version = process.env.BUILD_VERSION, t
'^/custom.css': {
target: fetchBaseUrl(),
},
'^/code-snippets.js': {
target: fetchBaseUrl(),
},
},
watchOptions: {

View File

@ -9,6 +9,7 @@ system:
applications: Applications
authclients: Auth Clients
connections: Connections
code-snippets: Code Snippets
sensitivityLevel: Sensitivity Levels
permissions: Permissions
queues: Messaging Queues
@ -43,4 +44,3 @@ ui:
group: User interface
items:
settings: Settings

View File

@ -268,6 +268,16 @@ settings:
update:
success: UI settings updated
error: UI settings update failed
code-snippet:
fetch:
error: Code snippets settings fetch failed
update:
success: Code snippets settings updated
error: Code snippets settings update failed
delete:
success: Code snippet deleted
error: Code snippet delete failed
permissions:
fetch:
error: Permissions fetch failed
@ -293,4 +303,4 @@ connection:
error: Connection delete failed
undelete:
success: Connection restored
error: Connection restore failed
error: Connection restore failed

View File

@ -0,0 +1,15 @@
editor:
title: Code snippets
code-snippets:
add: Add code snippet
empty: No snippets yet
table-headers:
provider: Provider
value: Script value
form:
provider:
label: Provider
value:
label: Script value
description: Ensure that your Code snippets are within <script> tags to properly inject them into Corteza web applications.

View File

@ -87,3 +87,4 @@ editor:
invalid-handle-characters: Should be at least 2 characters long. Can contain only letters, numbers, underscores and dots. Must end with letter or number
url: URL
new-tab: New tab

View File

@ -2,14 +2,18 @@ package webapp
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"regexp"
"strings"
"golang.org/x/net/html"
"github.com/cortezaproject/corteza/server/pkg/logger"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/system/service"
@ -28,6 +32,8 @@ type (
sentryUrl string
settings *types.AppSettings
}
scriptAttrs = map[string]string
)
var (
@ -152,6 +158,63 @@ func serveConfig(r chi.Router, config webappConfig) {
_, _ = fmt.Fprint(w, stylesheet)
})
// serve code-snippets.js
r.Get(options.CleanBase(config.appUrl, "code-snippets.js"), func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "text/javascript")
codeSnippets := service.CurrentSettings.CodeSnippets
snippetScripts := ""
for _, snippet := range codeSnippets {
snippetScripts += snippet.Script
}
doc, err := html.Parse(strings.NewReader(snippetScripts))
if err != nil {
log.Fatal(err)
}
scriptsAttrs := traverseScriptsNode(doc)
//create a javascript array of objects
snippetScriptsJson, err := json.Marshal(scriptsAttrs)
if err != nil {
log.Fatal(err)
}
jsScripts := fmt.Sprintf(
`
const snippetScripts = %s;
if (snippetScripts !== null) {
snippetScripts.forEach(snippetScript => {
const scriptAttr = document.createElement("script");
if (snippetScript.src) {
scriptAttr.src = snippetScript.src;
}
if (snippetScript.integrity) {
scriptAttr.integrity = snippetScript.integrity;
}
if (snippetScript.crossorigin) {
scriptAttr.crossOrigin = snippetScript.crossorigin;
}
if (snippetScript.content) {
scriptAttr.textContent = snippetScript.content;
}
document.head.appendChild(scriptAttr);
});
}
`, string(snippetScriptsJson))
_, _ = fmt.Fprint(w, jsScripts)
})
}
// Reads and modifies index HTML for the webapp
@ -183,3 +246,34 @@ func replaceBaseHrefPlaceholder(buf []byte, app, baseHref string) []byte {
return fixed
}
func traverseScriptsNode(n *html.Node) []scriptAttrs {
var scripts []scriptAttrs
if n.Type == html.ElementNode && n.Data == "script" {
script := scriptAttrs{}
for _, attr := range n.Attr {
switch attr.Key {
case "src":
script["src"] = attr.Val
case "integrity":
script["integrity"] = attr.Val
case "crossorigin":
script["crossorigin"] = attr.Val
}
}
if n.FirstChild != nil {
script["content"] = n.FirstChild.Data
}
scripts = append(scripts, script)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
childScripts := traverseScriptsNode(c)
scripts = append(scripts, childScripts...)
}
return scripts
}

View File

@ -238,6 +238,8 @@ type (
// Integration gateway settings
Apigw ApigwSettings `kv:"apigw" json:"apigw"`
CodeSnippets []struct{ CodeSnippet } `kv:"code-snippets" json:"codeSnippets"`
// UserInterface settings
UI struct {
MainLogo string `kv:"main-logo" json:"mainLogo"`
@ -415,6 +417,11 @@ type (
Values string `json:"values"`
}
CodeSnippet struct {
Name string `json:"name"`
Script string `json:"script"`
}
SmtpServers struct {
Host string `json:"host"`
Port int `json:"port,string"`