snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s

This commit is contained in:
upstream-mirror
2026-09-18 01:22:23 +05:00
commit 7e385fc126
8391 changed files with 1766236 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

View File

@@ -0,0 +1,16 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
# Tab indentation (no size specified)
[Makefile]
indent_style = tab

View File

@@ -0,0 +1,10 @@
module.exports = {
extends: ['corteza-client'],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@babel/eslint-parser',
requireConfigFile: false,
ecmaVersion: 2020,
sourceType: 'module',
},
}

20
client/web/admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
.DS_Store
node_modules
dist
*.log
# Editor directories and files
.idea
.vscode
*.iml
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
.nyc_output
coverage
/.act*
/workflow

39
client/web/admin/Makefile Normal file
View File

@@ -0,0 +1,39 @@
.PHONY: dep test build release upload
YARN_FLAGS ?= --non-interactive --no-progress --silent --emoji false
YARN = yarn $(YARN_FLAGS)
REPO_NAME ?= corteza-webapp-admin
BUILD_FLAVOUR ?= corteza
BUILD_FLAGS ?= --production
BUILD_DEST_DIR = dist
BUILD_TIME ?= $(shell date +%FT%T%z)
BUILD_VERSION ?= $(shell git describe --tags --abbrev=0)
BUILD_NAME = $(REPO_NAME)-$(BUILD_VERSION)
RELEASE_NAME = $(BUILD_NAME).tar.gz
RELEASE_EXTRA_FILES ?= README.md LICENSE CONTRIBUTING.md DCO
RELEASE_PKEY ?= .upload-rsa
dep:
$(YARN) install
test:
$(YARN) lint
$(YARN) test:unit
build:
$(YARN) build $(BUILD_FLAGS)
release:
@ cp $(RELEASE_EXTRA_FILES) $(BUILD_DEST_DIR)
@ tar -C $(BUILD_DEST_DIR) -czf $(RELEASE_NAME) $(dir $(BUILD_DEST_DIR))
upload: $(RELEASE_PKEY)
@ echo "put *.tar.gz" | sftp -q -o "StrictHostKeyChecking no" -i $(RELEASE_PKEY) $(RELEASE_SFTP_URI)
@ rm -f $(RELEASE_PKEY)
$(RELEASE_PKEY):
@ echo $(RELEASE_SFTP_KEY) | base64 -d > $(RELEASE_PKEY)
@ chmod 0400 $@

View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
['@vue/app', { useBuiltIns: 'entry' }],
],
}

View File

@@ -0,0 +1,36 @@
/** @type {import('jest').Config} */
module.exports = {
preset: '@vue/cli-plugin-unit-jest',
testMatch: [
'**/tests/unit/**/*.spec.js',
],
moduleNameMapper: {
'^corteza-webapp-admin/(.*)$': '<rootDir>/$1',
},
moduleFileExtensions: [
'js',
'jsx',
'json',
'vue',
],
transform: {
'^.+\\.vue$': '@vue/vue2-jest',
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
'^.+\\.jsx?$': 'babel-jest',
},
transformIgnorePatterns: [
'/node_modules/(?!(chai|sinon)/)',
],
snapshotSerializers: [
'jest-serializer-vue',
],
testEnvironment: 'jsdom',
testURL: 'http://localhost/',
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname',
],
setupFiles: [
'<rootDir>/tests/setup.js',
],
}

View File

@@ -0,0 +1,94 @@
{
"name": "corteza-webapp-admin",
"description": "Corteza Admin WebApp",
"version": "2024.9.10",
"license": "Apache-2.0",
"contributors": [
"Denis Arh <denis.arh@gmail.com>"
],
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"dev-env": "vue-cli-service serve src/dev-env.js",
"build": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint --fix",
"cdeps": "yarn link @cortezaproject/corteza-js @cortezaproject/corteza-vue",
"test:unit": "vue-cli-service test:unit",
"test": "yarn test:unit"
},
"gitHooks": {
"pre-commit": "yarn lint"
},
"stylelint": {
"plugins": [
"stylelint-scss"
],
"extends": "stylelint-config-standard",
"rules": {
"color-hex-length": null,
"no-empty-source": null
}
},
"resolutions": {
"cross-spawn": "^7.0.6",
"postcss": "^8.5.3"
},
"dependencies": {
"@cortezaproject/corteza-js": "^2024.9.10",
"@cortezaproject/corteza-vue": "2024.9.10",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^2.0.10",
"@panter/vue-i18next": "^0.15.2",
"axios": "^1.8.3",
"bootstrap-vue": "2.23.1",
"copy-to-clipboard": "^3.3.1",
"echarts": "5.6.0",
"fstream": "^1.0.12",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4",
"lodash": "^4.17.21",
"minimalist": "^1.0.0",
"minimist": "^1.2.6",
"moment": "2.30.1",
"portal-vue": "^2.1.7",
"vue": "2.7.16",
"vue-echarts": "7.0.3",
"vue-native-websocket": "^2.0.15",
"vue-router": "3.1.5",
"vuedraggable": "^2.23.0",
"vuex": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.26.7",
"@babel/eslint-parser": "^7.26.5",
"@vue/cli-plugin-babel": "5.0.8",
"@vue/cli-plugin-eslint": "5.0.8",
"@vue/cli-plugin-unit-jest": "^5.0.8",
"@vue/cli-service": "5.0.8",
"@vue/eslint-config-standard": "^8.0.1",
"@vue/test-utils": "^1.3.6",
"@vue/vue2-jest": "^27.0.0",
"babel-jest": "^27.1.0",
"chai": "^4.3.7",
"chai-as-promised": "^7.1.1",
"eslint": "^8.7.2",
"eslint-config-corteza-client": "file:../../../lib/eslint-client",
"eslint-plugin-vue": "^9.32.0",
"jest": "^27.1.0",
"null-loader": "^4.0.1",
"resolve-url-loader": "^5.0.0",
"rtlcss": "^4.3.0",
"sass": "^1.85.1",
"sass-loader": "^10",
"sinon": "^9.2.4",
"stylelint": "16.14.1",
"stylelint-config-standard": "^37.0.0",
"stylelint-scss": "^6.11.0",
"stylelint-webpack-plugin": "^5.0.1",
"vue-eslint-parser": "^9.4.3",
"vue-template-compiler": "2.7.16",
"webpack": "^5.97.1"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
autoprefixer: {},
rtlcss: {},
},
}

1
client/web/admin/public/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
config.js

View File

@@ -0,0 +1,17 @@
// Corteza API location
window.CortezaAPI = 'https://api.cortezaproject.your-domain.tld';
// CortezaAuth can be autoconfigured by replacing /api with /auth in CortezaAPI
// or by appending /auth to the end of CortezaAPI string
// When this is not possible and your configuration is more exotic you can set it
// explicitly:
// window.CortezaAuth = 'https://api.cortezaproject.your-domain.tld/auth';
// Configure CortezaWebapp when your web applications are not placed on the root.
// This is autoconfigured from the value of <base> tag href attribute in most cases.
// window.CortezaWebapp = 'https://cortezaproject.your-domain.tld';
// Set to true to enable i18next-pseudo
// Used to test translation string in a development environment
// Even if set to true, it will be disabled in production
window.i18nPseudoModeEnabled = false

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.0075 -0.0058 36.9075 38.5121" width="32px" height="32px">
<defs>
<style type="text/css">
.st0{fill:#95B4AF;}
</style>
</defs>
<path class="st0" d="M2.5,9.4c1.6-2.9,4-5.3,6.9-6.9c3-1.7,6.3-2.5,9.8-2.5c3.9-0.1,7.8,1.1,11.1,3.2c3.2,2.2,5.5,5.3,6.6,9h-8.7 c-0.8-1.7-2-3.1-3.6-4.1c-1.7-0.9-3.5-1.4-5.4-1.3c-2.1,0-4.1,0.5-5.9,1.5c-1.7,1-3.1,2.6-4,4.4c-1,2.1-1.5,4.3-1.4,6.6 c0,2.3,0.4,4.5,1.4,6.6c0.9,1.8,2.3,3.3,4,4.4c1.8,1,3.8,1.6,5.9,1.5c1.9,0,3.8-0.4,5.4-1.4c1.6-1,2.9-2.4,3.6-4.1h8.7 c-1.1,3.7-3.5,6.8-6.6,9c-3.3,2.2-7.2,3.3-11.1,3.2c-3.4,0-6.8-0.8-9.8-2.5c-2.9-1.6-5.2-4-6.9-6.8c-1.7-3-2.6-6.5-2.5-10 C-0.1,15.9,0.8,12.4,2.5,9.4z" id="object-0" style="fill: rgb(255, 150, 97);"/>
</svg>

After

Width:  |  Height:  |  Size: 792 B

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html data-color-mode="light" lang="en">
<head>
<!--
base location is root by default for all webapps
href value should be modified when app is placed under subdir
-->
<base href="/" />
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" id="favicon" href="">
<link rel="stylesheet" id="corteza-custom-css" type="text/css" href="<%= BASE_URL %>custom.css">
<link rel="preload" href="<%= BASE_URL %>config.js" as="script" />
<title><%= FLAVOUR %></title>
</head>
<body id="admin">
<noscript>
<strong>
JavaScript is disabled in your browser!
</strong>
<p>
We're sorry but Corteza doesn't work properly without JavaScript enabled.
Please enable it to continue.
</p>
</noscript>
<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>

227
client/web/admin/src/app.js Normal file
View File

@@ -0,0 +1,227 @@
import Vue from 'vue'
import './config-check'
import './console-splash'
import './components'
import './filters'
import './mixins'
import './plugins'
import router from './router'
import store from './store'
import { system } from '@cortezaproject/corteza-js'
import { corredor, i18n, mixins, websocket } from '@cortezaproject/corteza-vue'
import { mapGetters } from 'vuex'
const notProduction = (process.env.NODE_ENV !== 'production')
export default (options = {}) => {
options = {
el: '#app',
name: 'admin',
template: '<router-view v-if="loaded && i18nLoaded && isRbacLoaded" />',
mixins: [
mixins.corredor,
],
data: () => ({
loaded: false,
i18nLoaded: false,
}),
computed: {
...mapGetters({
isRbacLoaded: 'rbac/isLoaded',
}),
},
async created () {
this.$i18n.i18next.on('initialized', () => {
this.i18nLoaded = true
})
this.websocket()
return this.$auth.vue(this).handle().then(async ({ user }) => {
// switch the favicon based on the settings
await this.$Settings.init({ api: this.$SystemAPI }).then(() => {
const icon = this.$Settings.attachment('ui.iconLogo') || '/icon.svg'
const favicon = document.getElementById('favicon')
if (favicon) {
favicon.href = icon
}
})
// switch the page directionality on body based on language
document.body.setAttribute('dir', this.textDirectionality(user.meta.preferredLanguage))
if (user.meta.preferredLanguage) {
// After user is authenticated, get his preferred language
// and instruct i18next to change it
this.$i18n.i18next.changeLanguage(user.meta.preferredLanguage)
}
// switch the webapp theme based on user preference
if (user.meta.theme) {
document.getElementsByTagName('html')[0].setAttribute('data-color-mode', user.meta.theme)
}
// ref to vue is needed inside compose helper
// load and register bundle and list of client/server scripts
const bundleLoaderOpt = {
// Name of the bundle to load
bundle: 'admin',
// Debug logging
verbose: notProduction,
// Context for exec function (client scripts only!)
//
// Extended with additional helpers
ctx: new corredor.WebappCtx({
$invoker: user,
authToken: this.$auth.accessToken,
}),
}
// Load all pending prompts:
this.$store.dispatch('wfPrompts/update')
// Only use enabled apis
const enabledApis = [this.$SystemAPI, this.$ComposeAPI, this.$AutomationAPI]
if (this.$Settings.get('federation.enabled', false)) {
enabledApis.push(this.$FederationAPI)
}
// Load effective permissions
this.$store.dispatch('rbac/load', enabledApis)
// Initialize notifications
this.$store.dispatch('notifications/fetchNotifications')
return this.loadBundle(bundleLoaderOpt)
.then(() => this.$SystemAPI.automationList({ excludeInvalid: true }))
.then(this.makeAutomationScriptsRegistrator(
// compose specific handler that routes onManual events for server-scripts
// to the proper endpoint on the API
system.TriggerSystemServerScriptOnManual(this.$SystemAPI),
))
.then(() => {
this.loaded = true
})
}).catch((err) => {
if (err instanceof Error && err.message === 'Unauthenticated') {
// user not logged-in,
// start with authentication flow
this.$auth.startAuthenticationFlow()
return
}
throw err
})
},
methods: {
/**
* Registers event listener for websocket messages and
* routes them depending on their type
*/
websocket () {
// cross-link auth & websocket so that ws can use the right access token
websocket.init(this)
// register event listener for workflow messages
this.$on('websocket-message', ({ data }) => {
const msg = JSON.parse(data)
switch (msg['@type']) {
case 'workflowSessionPrompt':
this.$store.dispatch('wfPrompts/new', msg['@value'])
break
case 'workflowSessionResumed':
this.$store.dispatch('wfPrompts/clear', msg['@value'])
break
case 'notification':
this.$store.dispatch('notifications/addNotification', msg['@value'])
break
case 'notification.read':
this.$store.dispatch('notifications/updateReadNotification', msg['@value'])
break
case 'notification.unread':
this.$store.dispatch('notifications/updateUnreadNotification', msg['@value'])
break
case 'notification.read.all':
this.$store.dispatch('notifications/updateAllReadNotifications', msg['@value'])
break
case 'notification.unread.all':
this.$store.dispatch('notifications/updateAllUnreadNotifications', msg['@value'])
break
case 'notification.delete':
this.$store.dispatch('notifications/removeNotification', msg['@value'])
break
case 'error':
this.toastDanger('Websocket message with error', msg['@value'])
}
})
},
},
router,
store,
i18n: i18n(Vue,
{ app: 'corteza-webapp-admin' },
'admin',
'automation.scripts',
'automation.sessions',
'automation.workflows',
'compose.automation',
'compose.settings',
'dashboard',
'federation.nodes',
'general',
'navigation',
'notification',
'notifications',
'permissions',
'system.actionlog',
'system.apigw',
'system.applications',
'system.authclients',
'system.code-snippets',
'system.connections',
'system.email',
'system.queues',
'system.roles',
'system.sensitivityLevel',
'system.settings',
'system.templates',
'system.user-groups',
'system.users',
'ui.settings',
),
// Any additional options we want to merge
...options,
}
const app = new Vue(options)
// Simple HMR acceptance
if (module.hot) {
module.hot.accept()
}
return app
}

View File

@@ -0,0 +1,113 @@
<template>
<div>
<b-modal
:visible="visible"
size="lg"
:title="(filter || {}).label"
:ok-title="$t('filters.modal.ok')"
body-class="p-0"
cancel-variant="light"
@ok="onSave"
@hidden="onHidden"
>
<div
v-if="internalFilter"
class="card-body"
>
<c-filter-params
:filter="internalFilter"
/>
<b-form-checkbox
v-model="internalFilter.enabled"
data-test-id="checkbox-filter-enable"
>
{{ $t('filters.enabled') }}
</b-form-checkbox>
</div>
</b-modal>
</div>
</template>
<script>
import CFilterParams from 'corteza-webapp-admin/src/components/Apigw/CFilterParams'
export default {
components: {
CFilterParams,
},
props: {
filter: {
type: Object,
default: () => ({}),
},
visible: {
type: Boolean,
required: false,
default: false,
},
},
data () {
return {
internalFilter: undefined,
}
},
watch: {
visible: {
handler (visible) {
if (visible) {
// Convert to FE structure
this.internalFilter = {
...this.filter,
params: this.filter.params.map(p => {
let value = p.value || {}
if (this.filter.ref === 'response') {
if (p.type === 'header') {
value = Object.entries(value).map(([name, v = []]) => ({ name, expr: v.join('') }))
} else if (p.type === 'input') {
value = { type: 'Any', expr: '', ...value }
}
}
return { ...p, value }
}),
}
} else {
this.internalFilter = undefined
}
},
},
},
methods: {
onSave () {
// Convert to BE structure
const filter = {
...this.internalFilter,
params: this.internalFilter.params.map(p => {
if (this.filter.ref === 'response') {
if (p.type === 'header') {
p.value = p.value.reduce((obj, { name, expr = '' }) => {
return { ...obj, [name]: [expr] }
}, {})
}
}
return p
}),
}
this.$emit('submit', { ...filter, updated: true })
},
onHidden () {
this.internalFilter = undefined
this.$emit('reset')
},
},
}
</script>

View File

@@ -0,0 +1,202 @@
<template>
<div v-if="filter.params.length">
<b-form-group
v-for="(param, index) in filter.params"
:key="index"
class="text-primary"
>
<template slot="label">
{{ $t(`filters.labels.${param.label}`) }}
<template v-if="param.label === 'expr'">
<a
v-if="param.label === 'expr'"
:href="documentationURL"
target="_blank"
>
<font-awesome-icon
:icon="['far', 'question-circle']"
/>
</a>
</template>
</template>
<!-- TODO create multi field component-->
<b-form-checkbox
v-if="param.type === 'bool'"
v-model="param.value"
/>
<c-input-select
v-else-if="param.label === 'workflow'"
v-model="param.value"
:options="workflows"
:get-option-key="getOptionKey"
:reduce="wf => wf.workflowID"
:placeholder="$t('filters.placeholders.workflow')"
:loading="loadingWorkflows"
/>
<b-form-select
v-else-if="param.label === 'status'"
v-model="param.value"
:options="httpStatusOptions"
>
<template #first>
<b-form-select-option
:value="undefined"
>
{{ $t('filters.httpStatus.none') }}
</b-form-select-option>
</template>
</b-form-select>
<template v-else-if="filter.ref === 'response'">
<template v-if="param.type === 'input'">
<b-form-select
v-model="param.value.type"
:options="inputTypeOptions"
class="mb-2"
/>
<b-input-group>
<b-input-group-prepend>
<b-input-group-text variant="extra-light">
ƒ
</b-input-group-text>
</b-input-group-prepend>
<b-form-input
v-model="param.value.expr"
:placeholder="$t('filters.help.expression.example')"
/>
</b-input-group>
</template>
<template v-else>
<c-form-table-wrapper
:labels="{ addButton: $t('filters.addHeader') }"
@add-item="param.value.push({ name: '', expr: '' })"
>
<b-input-group
v-for="(header, hIndex) in param.value"
:key="`header-${hIndex}`"
class="mb-2"
>
<b-form-input
v-model="header.name"
:placeholder="$t('filters.labels.name')"
/>
<b-form-input
v-model="header.expr"
:placeholder="$t('filters.labels.value')"
/>
<c-input-confirm
show-icon
@confirmed="param.value.splice(hIndex, 1)"
/>
</b-input-group>
</c-form-table-wrapper>
</template>
</template>
<template v-else>
<b-form-textarea
v-if="param.label === 'jsfunc'"
v-model="param.value"
max-rows="6"
/>
<b-input-group v-else>
<b-input-group-prepend
v-if="param.label === 'expr'"
>
<b-input-group-text variant="extra-light">
ƒ
</b-input-group-text>
</b-input-group-prepend>
<b-form-input
v-if="param.label === 'expr'"
v-model="param.value"
:placeholder="$t('filters.help.expression.example')"
/>
<b-form-input
v-else
v-model="param.value"
/>
</b-input-group>
</template>
</b-form-group>
</div>
</template>
<script>
export default {
props: {
filter: {
type: Object,
default: () => ({}),
},
},
data () {
return {
loadingWorkflows: false,
workflows: [],
httpStatusOptions: [
{ value: 300, text: this.$t('filters.httpStatus.300') },
{ value: 301, text: this.$t('filters.httpStatus.301') },
{ value: 302, text: this.$t('filters.httpStatus.302') },
{ value: 303, text: this.$t('filters.httpStatus.303') },
{ value: 304, text: this.$t('filters.httpStatus.304') },
{ value: 307, text: this.$t('filters.httpStatus.307') },
{ value: 308, text: this.$t('filters.httpStatus.308') },
],
inputTypeOptions: [
'String',
'Any',
'Array',
'KV',
'DateTime',
'Float',
'Integer',
'Reader',
'Vars',
],
}
},
computed: {
documentationURL () {
// eslint-disable-next-line no-undef
const [year, month] = VERSION.split('.')
return `https://docs.cortezaproject.org/corteza-docs/${year}.${month}/integrator-guide/expr/index.html`
},
},
created () {
if (this.filter.params.some(({ label = '' }) => label === 'workflow')) {
this.loadingWorkflows = true
this.$AutomationAPI.workflowList()
.then(({ set: workflows = [] }) => {
this.workflows = workflows.map(({ workflowID, handle, meta }) => {
return { label: meta.name || handle, workflowID }
})
})
.finally(() => {
this.loadingWorkflows = false
})
}
},
methods: {
getOptionKey ({ workflowID }) {
return workflowID
},
},
}
</script>

View File

@@ -0,0 +1,71 @@
<template>
<b-dropdown
data-test-id="dropdown-add-filter"
:text="$t('filters.addFilter')"
variant="primary"
>
<template v-if="filterList.length">
<b-dropdown-item-button
v-for="(filter, index) in filterList"
:key="index"
:data-test-id="filterDropdownCypressId(filter.label)"
:disabled="filter.disabled"
@click="onAddFilter(filter)"
>
{{ filter.label }}
</b-dropdown-item-button>
</template>
<b-dropdown-item-button
v-else
disabled
>
<span
data-test-id="filter-list-empty"
class="text-danger"
>
{{ $t('filters.filterListEmpty') }}
</span>
</b-dropdown-item-button>
</b-dropdown>
</template>
<script>
export default {
props: {
availableFilters: {
type: Array,
required: true,
},
filters: {
type: Array,
required: true,
},
},
computed: {
filterList () {
return this.availableFilters.map(f => {
return { ...f, disabled: !!(this.filters || []).some(filter => filter.ref === f.ref) }
})
},
},
methods: {
onAddFilter (filter) {
const add = { ...filter, created: true, params: [] }
const { params = [] } = filter
for (const p of params) {
add.params.push({ ...p, options: { ...p.options } })
}
this.$emit('addFilter', add)
},
filterDropdownCypressId (filter) {
return filter.toLowerCase().split(' ').join('-')
},
},
}
</script>

View File

@@ -0,0 +1,206 @@
<template>
<b-card
data-test-id="card-filter-list"
body-class="p-0"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm mt-3"
>
<template #header>
<h4 class="m-0">
{{ $t('filters.title') }}
</h4>
</template>
<b-tabs
data-test-id="filter-steps"
card
>
<b-tab
v-for="(step, index) in steps"
:key="index"
:button-id="steps[index]"
:title="$t(`filters.step_title.${step}`)"
class="border-0 p-0"
@click="onActivateTab(index)"
>
<c-filters-dropdown
:available-filters="getAvailableFiltersByStep"
:filters="getSelectedFiltersByStep"
class="p-3"
@addFilter="onAddFilter"
/>
<c-filters-table
:filters="getSelectedFiltersByStep"
:step="index"
:fetching="fetching"
@filterSelect="onFilterSelect"
@removeFilter="onRemoveFilter"
@sortFilters="onSortFilters"
/>
</b-tab>
</b-tabs>
<template #footer>
<c-button-submit
:disabled="disabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit')"
/>
</template>
<c-filter-modal
:visible="!!selectedFilter"
:filter="selectedFilter"
@submit="onSubmit"
@reset="onReset"
/>
</b-card>
</template>
<script>
import CFilterModal from 'corteza-webapp-admin/src/components/Apigw/CFilterModal'
import CFiltersTable from 'corteza-webapp-admin/src/components/Apigw/CFiltersTable'
import CFiltersDropdown from 'corteza-webapp-admin/src/components/Apigw/CFiltersDropdown'
const mapKindToStep = {
prefilter: 0,
processer: 1,
postfilter: 2,
}
export default {
components: {
CFilterModal,
CFiltersTable,
CFiltersDropdown,
},
props: {
fetching: {
type: Boolean,
value: false,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
filters: {
type: Array,
required: true,
},
availableFilters: {
type: Array,
required: true,
},
steps: {
type: Array,
required: true,
},
},
data () {
return {
selectedFilter: null,
selectedTab: 0,
}
},
computed: {
disabled () {
return !(this.filters.some(({ updated, created, deleted }) => updated || created || deleted))
},
getSelectedFilter () {
return this.selectedFilter ? this.selectedFilter : null
},
getAvailableFiltersByStep () {
return (this.availableFilters || []).filter(({ kind }) => {
return mapKindToStep[kind] === this.selectedTab
})
},
getSelectedFiltersByStep () {
return (this.filters || []).filter(({ kind, deleted }) => {
return mapKindToStep[kind] === this.selectedTab && !deleted
}).sort((a, b) => a.weight - b.weight)
},
disabledRemoveButton () {
return !this.filters.some(({ options }) => (options || { checked: false }).checked)
},
},
methods: {
onAddFilter (filter) {
const i = this.filters.findIndex(({ ref, deleted }) => ref === filter.ref && !deleted)
if (i < 0) {
this.selectedFilter = filter
} else {
this.selectedFilter = this.filters[i]
}
},
onSubmit (filter) {
const i = this.filters.findIndex(({ ref, deleted }) => ref === filter.ref && !deleted)
if (i < 0) {
filter.weight = this.getSelectedFiltersByStep.length
this.filters.push(filter)
} else {
this.filters.splice(this.filters.findIndex(({ ref, deleted }) => ref === filter.ref && !deleted), 1, filter)
}
this.$emit('update:filters', this.filters)
},
onReset () {
this.selectedFilter = undefined
},
onSortFilters (sortedFilters) {
this.filters.forEach(filter => {
const i = sortedFilters.findIndex(({ ref, deleted }) => ref === filter.ref && !deleted)
if (i >= 0) {
filter.weight = sortedFilters.findIndex(({ ref, deleted }) => ref === filter.ref && !deleted)
filter.updated = true
}
})
this.filters.sort((a, b) => a.weight - b.weight)
},
onRemoveFilter (filter) {
if (filter.filterID) {
this.filters.splice(this.filters.findIndex(({ filterID, deleted }) => filterID === filter.filterID && !deleted), 1, { ...filter, deleted: true })
} else {
this.filters.splice(this.filters.findIndex(({ ref, deleted }) => ref === filter.ref && !deleted), 1)
}
this.$emit('update:filters', this.filters)
},
onFilterSelect (filter = {}) {
this.selectedFilter = { ...filter }
},
onActivateTab (index) {
this.selectedTab = index
},
},
}
</script>

View File

@@ -0,0 +1,139 @@
<template>
<div>
<b-table-simple
hover
class="mb-0"
>
<b-thead head-variant="light">
<b-tr>
<b-th />
<b-th>{{ $t('filters.list.filters') }}</b-th>
<b-th>{{ $t('filters.list.status') }}</b-th>
<b-th />
</b-tr>
</b-thead>
<draggable
v-if="!fetching"
v-model="sortableFilters"
:options="{ handle: '.handle' }"
tag="b-tbody"
>
<b-tr
v-for="(filter, index) in sortableFilters"
:key="index"
class="pointer"
@click="onRowClick(filter, index)"
>
<td
class="handle align-middle grab"
style="width: 1%"
@click.stop
>
<font-awesome-icon
:icon="['fas', 'bars']"
class="text-secondary"
/>
</td>
<b-td class="align-middle">
{{ filter.label }}
</b-td>
<b-td class="align-middle">
{{ $t(`filters.${filter.enabled ? 'enabled' : 'disabled'}`) }}
</b-td>
<b-td class="text-right align-middle">
<c-input-confirm
show-icon
class="ml-1"
@confirmed="onRemoveFilter(filter)"
@click.stop
/>
</b-td>
</b-tr>
</draggable>
</b-table-simple>
<div
class="d-flex flex-column align-items-center justify-content-center h-100 overflow-hidden"
>
<b-spinner
v-if="fetching"
class="my-4"
/>
<p
v-else-if="!sortableFilters.length"
data-test-id="no-filters"
class="my-4"
>
{{ $t('filters.list.noFilters') }}
</p>
</div>
</div>
</template>
<script>
import draggable from 'vuedraggable'
export default {
components: {
draggable,
},
props: {
filters: {
type: Array,
required: true,
},
step: {
type: Number,
default: () => 0,
},
fetching: {
type: Boolean,
value: false,
},
},
data () {
return {
selectedRow: 0,
selectedFilter: {},
}
},
computed: {
sortableFilters: {
get () {
return this.filters
},
set (v) {
this.$emit('sortFilters', v)
},
},
},
methods: {
onAddFilter (filter) {
if (!this.filters.find(f => f.ref === filter.ref)) {
this.filters.push({ ...filter })
}
if (this.filters.length === 1) {
this.$emit('filterSelect', filter)
}
},
onRemoveFilter (filter) {
this.$emit('removeFilter', filter)
},
onRowClick (filter, index) {
this.selectedRow = index
this.selectedFilter = filter
this.$emit('filterSelect', filter)
},
},
}
</script>

View File

@@ -0,0 +1,227 @@
<template>
<b-card
data-test-id="card-route-edit"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
@submit.prevent="$emit('submit', route)"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:description="routeEndpointDescription"
label-class="text-primary"
>
<template
#label
>
<label
label-for="endpoint"
class="mb-0"
>
{{ $t('endpoint') }}
</label>
<font-awesome-icon
id="endpoint_info"
class="ml-1"
:icon="['far', 'question-circle']"
/>
<b-tooltip
target="endpoint_info"
triggers="hover"
>
{{ $t('tooltip') }}
</b-tooltip>
</template>
<b-form-input
id="endpoint"
v-model="route.endpoint"
data-test-id="input-endpoint"
:state="isValidEndpoint"
required
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('method')"
label-class="text-primary"
>
<b-form-select
v-model="route.method"
data-test-id="select-method"
:options="methods"
required
/>
</b-form-group>
</b-col>
<b-col
v-if="route.meta"
cols="12"
lg="6"
>
<b-form-group
:label="$t('description')"
label-class="text-primary"
>
<b-form-textarea
v-model="route.meta.description"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('enabled')"
:class="{ 'mb-0': !route.routeID }"
label-class="text-primary"
>
<c-input-checkbox
v-model="route.enabled"
switch
:labels="checkboxLabel"
data-test-id="checkbox-enabled"
/>
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:id="route.routeID"
:resource="route"
/>
</b-form>
<template #footer>
<c-input-confirm
v-if="route && route.routeID && route.canDeleteApigwRoute"
:data-test-id="deletedButtonStatusCypressId"
variant="danger"
:text="getDeleteStatus"
size="md"
@confirmed="$emit('delete')"
/>
<c-button-submit
:disabled="saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', route)"
/>
</template>
</b-card>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
export default {
name: 'CRouteEditorInfo',
i18nOptions: {
namespaces: ['system.apigw'],
keyPrefix: 'editor.info',
},
props: {
route: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
data () {
return {
methods: ['GET', 'POST', 'PUT', 'DELETE'],
checkboxLabel: {
on: this.$t('general:label.general.yes'),
off: this.$t('general:label.general.no'),
},
}
},
computed: {
fresh () {
return !this.route.routeID || this.route.routeID === NoID
},
editable () {
return this.fresh ? this.canCreate : true // this.route.canUpdateRoute
},
saveDisabled () {
return !this.editable || [this.isValidEndpoint].includes(false)
},
getDeleteStatus () {
return this.route.deletedAt ? this.$t('undelete') : this.$t('delete')
},
isValidEndpoint () {
const { endpoint } = this.route
return (!!endpoint && /^(\/[\w-]+)+$/.test(endpoint)) ? null : false
},
startsWithSlash () {
return this.route.endpoint ? /^\//.test(this.route.endpoint) : null
},
routeEndpointDescription () {
if (this.isValidEndpoint === false) {
if (!this.startsWithSlash) {
return this.$t('validation.slash')
} else if (this.route.endpoint.length < 2) {
return this.$t('validation.minLength')
} else if (!/^([\w-]+)+$/.test(this.route.endpoint)) {
return this.$t('validation.invalidCharacters')
}
}
return ''
},
deletedButtonStatusCypressId () {
return `button-${this.getDeleteStatus.toLowerCase()}`
},
},
}
</script>

View File

@@ -0,0 +1,236 @@
<template>
<b-card
class="shadow-sm"
header-class="border-bottom"
body-class="p-0"
>
<template
#header
>
<h4 class="mb-0">
{{ $t('title') }}
</h4>
</template>
<c-resource-list
:primary-key="primaryKey"
:filter="filter"
:sorting="sorting"
:pagination="pagination"
:fields="fields"
:items="items"
:row-class="genericRowClass"
:translations="{
searchPlaceholder: $t('filterForm.query.placeholder'),
notFound: $t('admin:general.notFound'),
noItems: $t('admin:general.resource-list.no-items'),
loading: $t('loading'),
showingPagination: 'admin:general.pagination.showing',
singlePluralPagination: 'admin:general.pagination.single',
prevPagination: $t('admin:general.pagination.prev'),
nextPagination: $t('admin:general.pagination.next'),
resourceSingle: $t('general:label.route.single'),
resourcePlural: $t('general:label.route.plural')
}"
clickable
card-header-class="rounded-0"
class="h-100 bg-transparent"
@search="filterList"
@row-clicked="handleRowClicked"
>
<template #header>
<b-button
v-if="canCreate"
data-test-id="button-add"
variant="primary"
size="lg"
:to="{ name: 'system.apigw.new' }"
>
{{ $t('new') }}
</b-button>
<b-button
v-if="$Settings.get('apigw.profiler.enabled', false)"
data-test-id="button-profiler"
variant="info"
size="lg"
:to="{ name: 'system.apigw.profiler' }"
>
{{ $t('profiler') }}
</b-button>
<c-permissions-button
v-if="canGrant"
data-test-id="button-permissions"
resource="corteza::system:apigw-route/*"
:button-label="$t('permissions')"
size="lg"
/>
</template>
<template #toolbar>
<c-resource-list-status-filter
v-model="filter.deleted"
data-test-id="filter-deleted-routes"
:label="$t('filterForm.deleted.label')"
:excluded-label="$t('filterForm.excluded.label')"
:inclusive-label="$t('filterForm.inclusive.label')"
:exclusive-label="$t('filterForm.exclusive.label')"
@change="filterList"
/>
</template>
<template #actions="{ item: r }">
<b-dropdown
v-if="(areActionsVisible({ resource: r, conditions: ['canDeleteApigwRoute', 'canGrant'] }))"
boundary="viewport"
variant="outline-extra-light"
toggle-class="d-flex align-items-center justify-content-center text-primary border-0 py-2"
no-caret
dropleft
lazy
menu-class="m-0"
>
<template #button-content>
<font-awesome-icon
:icon="['fas', 'ellipsis-v']"
/>
</template>
<c-permissions-button
v-if="r.routeID && canGrant"
:title="r.endpoint || r.routeID"
:target="r.endpoint || r.routeID"
:resource="`corteza::system:apigw-route/${r.routeID}`"
:button-label="$t('permissions')"
class="dropdown-item"
/>
<c-input-confirm
v-if="r.canDeleteApigwRoute"
:text="getActionText(r)"
show-icon
:icon="getActionIcon(r)"
borderless
variant="link"
size="md"
button-class="dropdown-item"
icon-class="text-danger"
class="w-100"
@confirmed="handleDelete(r)"
/>
</b-dropdown>
</template>
</c-resource-list>
</b-card>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import listHelpers from 'corteza-webapp-admin/src/mixins/listHelpers'
import moment from 'moment'
import { components } from '@cortezaproject/corteza-vue'
const { CResourceList } = components
export default {
components: {
CResourceList,
},
mixins: [
listHelpers,
],
i18nOptions: {
namespaces: 'system.apigw',
keyPrefix: 'list',
},
data () {
return {
primaryKey: 'routeID',
editRoute: 'system.apigw.edit',
filter: {
query: '',
deleted: 0,
},
sorting: {
sortBy: 'createdAt',
sortDesc: true,
},
fields: [
{
key: 'endpoint',
sortable: true,
},
{
key: 'method',
sortable: false,
},
{
key: 'enabled',
formatter: (v) => v ? 'Yes' : 'No',
},
{
key: 'createdAt',
sortable: true,
formatter: (v) => moment(v).fromNow(),
},
{
key: 'actions',
class: 'actions',
},
].map(c => ({
...c,
// Generate column label translation key
label: this.$t(`columns.${c.key}`),
})),
}
},
computed: {
...mapGetters({
can: 'rbac/can',
}),
canCreate () {
return this.can('system/', 'apigw-route.create')
},
canGrant () {
return this.can('system/', 'grant')
},
},
methods: {
...mapActions({
incLoader: 'ui/incLoader',
decLoader: 'ui/decLoader',
}),
items () {
return this.procListResults(this.$SystemAPI.apigwRouteListCancellable(this.encodeListParams()))
},
handleDelete (route) {
this.handleItemDelete({
resource: route,
resourceName: 'apigwRoute',
locale: 'gateway',
})
},
},
}
</script>
<style lang="scss">
.route-list {
.card-header {
border-radius: 0;
}
}
</style>

View File

@@ -0,0 +1,117 @@
<template>
<b-card
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form-row
@submit.prevent="$emit('submit', settings)"
>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('profiler.label')"
label-class="text-primary"
>
<b-form-radio-group
v-model="profilerSetting"
:options="profilerOptions"
button-variant="outline-primary"
size="sm"
buttons
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('proxy.label')"
label-class="text-primary"
>
<b-form-checkbox
v-model="settings['apigw.proxy.follow-redirects']"
>
{{ $t('proxy.follow') }}
</b-form-checkbox>
</b-form-group>
</b-col>
</b-form-row>
<template #footer>
<c-button-submit
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', settings)"
/>
</template>
</b-card>
</template>
<script>
export default {
name: 'CSettingsEditor',
i18nOptions: {
namespaces: 'system.apigw',
keyPrefix: 'settings',
},
props: {
settings: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
},
data () {
return {
profilerOptions: [
{ value: 'disabled', text: 'Disabled' },
{ value: 'filter', text: 'Enabled as filter' },
{ value: 'global', text: 'Enabled for all routes' },
],
}
},
computed: {
profilerSetting: {
get () {
if (this.settings['apigw.profiler.enabled']) {
return this.settings['apigw.profiler.global'] ? 'global' : 'filter'
}
return 'disabled'
},
set (setting) {
this.settings['apigw.profiler.enabled'] = ['filter', 'global'].includes(setting)
this.settings['apigw.profiler.global'] = setting === 'global'
},
},
},
}
</script>

View File

@@ -0,0 +1,322 @@
<template>
<div>
<b-card
data-test-id="card-general-info"
header-class="border-bottom"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('general.label') }}
</h4>
</template>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('general.id')"
label-class="text-primary"
>
<b-form-input
v-model="hit.ID"
plaintext
disabled
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('general.route')"
label-class="text-primary"
>
<b-form-input
v-model="request.route"
data-test-id="input-route"
plaintext
disabled
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('general.URL')"
label-class="text-primary"
>
<b-form-input
v-model="request.url"
plaintext
disabled
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('general.method')"
label-class="text-primary"
>
<b-form-input
v-model="request.method"
plaintext
disabled
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('general.statusCode')"
label-class="text-primary"
>
<div
class="d-flex align-items-center h-100"
>
<h5 class="mb-0">
<b-badge :variant="getStatusCodeVariant(request.statusCode)">
{{ request.statusCode }}
</b-badge>
</h5>
</div>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('general.remoteAddress')"
label-class="text-primary"
>
<b-form-input
v-model="request.remoteAddress"
plaintext
disabled
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('general.duration')"
label-class="text-primary"
>
<b-form-input
v-model="request.duration"
plaintext
disabled
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('general.start')"
label-class="text-primary"
>
<b-form-input
v-model="request.start"
plaintext
disabled
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('general.end')"
label-class="text-primary"
>
<b-form-input
v-model="request.end"
plaintext
disabled
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-button
v-if="showOpenRoute"
variant="light"
:to="{ name: 'system.apigw.edit', params: { routeID: request.routeID } }"
>
{{ $t('general.openRoute') }}
</b-button>
</b-col>
</b-row>
</b-card>
<b-card
header-class="border-bottom"
class="shadow-sm mt-3"
>
<template #header>
<h4 class="m-0">
{{ $t('headers.label') }}
</h4>
</template>
<b-row>
<b-col
v-for="header in request.headers"
:key="header.label"
cols="12"
lg="6"
>
<b-form-group
:label="header.label"
label-class="text-primary"
>
<b-form-input
v-model="header.value"
plaintext
disabled
/>
</b-form-group>
</b-col>
</b-row>
</b-card>
<b-card
header-class="border-bottom"
body-class="p-0"
class="shadow-sm mt-3 overflow-hidden"
>
<template #header>
<h4 class="m-0">
{{ $t('body.label') }}
</h4>
</template>
<c-ace-editor
:value="request.body"
:border="false"
lang="json"
min-height="400px"
show-line-numbers
read-only
/>
</b-card>
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
import { fmt, NoID } from '@cortezaproject/corteza-js'
const { CAceEditor } = components
export default {
name: 'CProfilerHitInfo',
i18nOptions: {
namespaces: ['system.apigw'],
keyPrefix: 'profiler.hit',
},
components: {
CAceEditor,
},
props: {
hit: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
computed: {
request () {
// eslint-disable-next-line camelcase
const { request = {}, body = '', route = NoID, time_duration: duration = 0, time_start, time_finish, http_status_code: statusCode } = this.hit || {}
const { URL = {}, RequestURI, Method, RemoteAddr, Header = {} } = request
const { Path } = URL
const headers = Object.entries(Header).map(([key, value = []]) => {
return { label: key, value: value.join('') }
})
// Try to parse body as json
let jsonBody = atob(body)
try {
jsonBody = JSON.stringify(JSON.parse(jsonBody), null, 2)
} catch (e) {}
return {
routeID: route,
route: Path,
url: RequestURI,
method: Method,
statusCode,
remoteAddress: RemoteAddr,
duration: `${duration.toFixed(2)} ms`,
start: fmt.fullDateTime(time_start),
end: fmt.fullDateTime(time_finish),
headers,
body: jsonBody,
}
},
showOpenRoute () {
return this.request.routeID !== NoID
},
},
methods: {
getStatusCodeVariant (statusCode = '') {
const codeVariants = {
2: 'success',
3: 'info',
4: 'danger',
5: 'warning',
}
return codeVariants[statusCode[0]]
},
},
}
</script>

View File

@@ -0,0 +1,321 @@
<template>
<b-card
data-test-id="card-requests"
no-body
footer-class="border-top border-top border-top d-flex align-items-center justify-content-center"
header-class="border-bottom"
class="shadow-sm"
>
<template #header>
<h4 class="mb-0">
{{ $t('general:label.requests') }}
</h4>
</template>
<div class="d-flex align-items-center flex-wrap flex-fill p-3 gap-1">
<div class="flex-fill">
<b-button
data-test-id="button-refresh"
variant="primary"
:disabled="loading"
@click="loadItems()"
>
{{ $t('general:label.refresh') }}
</b-button>
<span
class="ml-1"
:class="{ 'loading': loading }"
>
{{ autoRefreshLabel }}
</span>
</div>
<c-input-confirm
:disabled="!items.length"
:processing="processingPurge"
:text="$t('purge.this')"
variant="danger"
@confirmed="purgeRequests"
/>
</div>
<b-card-body
class="p-0"
>
<b-table
id="hit-list"
hover
responsive
head-variant="light"
class="mb-0"
primary-key="hitID"
:sort-by.sync="sorting.sortBy"
:sort-desc.sync="sorting.sortDesc"
:items="items"
:fields="fields"
:busy="loading"
no-local-sorting
@sort-changed="resetItems"
>
<template #cell(http_method)="row">
{{ row.item.request.Method }}
</template>
<template #cell(http_status_code)="row">
<h6 class="mb-0">
<b-badge :variant="getStatusCodeVariant(row.item.http_status_code)">
{{ row.item.http_status_code }}
</b-badge>
</h6>
</template>
<template #cell(content_length)="row">
{{ `${((row.item.request.ContentLength || 0) / 1000).toFixed(3)} kB` }}
</template>
<template #cell(actions)="row">
<b-button
data-test-id="button-edit-route"
variant="link"
class="p-0"
:to="{ name: 'system.apigw.profiler.hit', params: { hitID: row.item.hitID } }"
>
<font-awesome-icon
:icon="['fas', 'info-circle']"
class="text-primary"
/>
</b-button>
</template>
</b-table>
</b-card-body>
<template #footer>
<b-button
v-if="items.length"
variant="light"
:disabled="!hasNextPage || loading"
@click="loadMore()"
>
{{ $t('general:label.loadMore') }}
</b-button>
</template>
</b-card>
</template>
<script>
import { fmt } from '@cortezaproject/corteza-js'
import listHelpers from 'corteza-webapp-admin/src/mixins/listHelpers'
export default {
mixins: [
listHelpers,
],
i18nOptions: {
namespaces: ['system.apigw'],
keyPrefix: 'profiler',
},
props: {
// Must be base64
route: {
type: String,
default: '',
},
},
data () {
return {
processingPurge: false,
filter: {
routeID: '',
next: '',
before: '',
query: '',
},
sorting: {
sortBy: 'time_start',
sortDesc: true,
},
totalItems: 0,
items: [],
refresh: {
timer: undefined,
countdown: 0,
},
fields: [
{
key: 'time_start',
sortable: true,
formatter: v => v ? fmt.fullDateTime(v) : '',
},
{
key: 'time_finish',
sortable: true,
formatter: v => v ? fmt.fullDateTime(v) : '',
},
{
key: 'http_method',
sortable: true,
class: 'text-center',
},
{
key: 'http_status_code',
sortable: true,
class: 'text-center',
},
{
key: 'content_length',
sortable: true,
formatter: v => `${v || 0} bytes`,
class: 'text-right',
},
{
key: 'time_duration',
sortable: true,
formatter: v => v ? `${v.toFixed(2)} ms` : '',
class: 'text-right',
},
{
key: 'actions',
label: '',
class: 'text-right',
},
].map(c => ({
...c,
// Generate column label translation key
label: this.$t(`columns.${c.key}`),
})),
}
},
computed: {
loading () {
return !this.refresh.countdown
},
autoRefreshLabel () {
return !this.loading ? this.$t('refreshingIn', { seconds: this.refresh.countdown }) : this.$t('general:label.loading')
},
hasNextPage () {
return this.filter.next
},
},
watch: {
route: {
immediate: true,
handler (route) {
if (route) {
this.resetItems()
}
},
},
},
beforeDestroy () {
this.clearRefresh()
},
methods: {
loadItems ({ append = false } = {}) {
this.clearRefresh()
const oldBeforeID = this.filter.before
this.filter.before = append ? this.filter.before : ''
this.filter.routeID = this.route
this.pagination.limit = append ? 10 : this.totalItems
const { response } = this.$SystemAPI.apigwProfilerRouteCancellable(this.encodeListParams())
response().then(({ filter = {}, set = [] }) => {
const { next } = filter
this.filter = { ...this.filter, next }
this.items = [
...(append ? this.items : []),
...set.map(i => ({ ...i, hitID: i.ID })),
]
this.totalItems = append ? this.totalItems + set.length : this.totalItems
return { filter, set }
})
.catch(this.toastErrorHandler(this.$t('notification:gateway.profiler.fetch.error')))
.finally(() => {
if (!append) {
this.filter.before = oldBeforeID
}
this.startRefresh()
})
},
purgeRequests () {
this.processingPurge = false
const { routeID } = this.filter
this.$SystemAPI.apigwProfilerPurge({ routeID })
.then(() => {
this.loadItems()
this.toastSuccess(this.$t('notification:gateway.profiler.purge.success'))
})
.catch(this.toastErrorHandler(this.$t('notification:gateway.profiler.purge.error')))
.finally(() => {
this.processingPurge = true
})
},
resetItems (sorting = this.sorting) {
this.sorting = sorting
this.filter.before = ''
this.totalItems = 10
this.loadItems()
},
loadMore () {
this.filter.before = this.filter.next
this.loadItems({ append: true })
},
// To start the refresh countdown
startRefresh () {
this.refresh.countdown = 10
this.resetRefresh()
},
// If you need to temporarily stop the refresh countdown
clearRefresh () {
this.refresh.timer = clearTimeout(this.refresh.timer)
this.refresh.countdown = 0
},
resetRefresh () {
clearTimeout(this.refresh.timer)
this.refresh.timer = setTimeout(() => {
this.refresh.countdown--
if (this.refresh.countdown) {
this.resetRefresh()
} else {
this.loadItems()
}
}, 1000)
},
getStatusCodeVariant (statusCode = '') {
const codeVariants = {
2: 'success',
3: 'info',
4: 'danger',
5: 'warning',
}
return codeVariants[statusCode[0]]
},
},
}
</script>
<style>
</style>

View File

@@ -0,0 +1,2 @@
export { default as CApplicationEditorInfo } from './CApplicationEditorInfo.c3'
export { default as CApplicationEditorUnify } from './CApplicationEditorUnify.c3'

View File

@@ -0,0 +1,46 @@
import { default as component } from './CApplicationEditorInfo.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
application: { applicationID: '' },
processing: false,
success: false,
canCreate: true,
}
export default {
name: 'Editor info',
group: ['Applications'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
checkbox('CanCreate', 'canCreate'),
checkbox('Enable delete', {
value (p) { return p.application.applicationID.length > 0 },
update (p, val) { p.application.applicationID = val ? '123456789' : '' },
}),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
application: {
...props.application,
enabled: false,
name: '',
},
canCreate: false,
},
},
],
}

View File

@@ -0,0 +1,161 @@
<template>
<b-card
data-test-id="card-application-info"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<b-form
@submit.prevent="$emit('submit', application)"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('name')"
label-class="text-primary"
>
<b-form-input
v-model="application.name"
data-test-id="input-name"
:state="nameState"
required
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('enabled')"
:class="{ 'mb-0': !application.applicationID }"
label-class="text-primary"
>
<c-input-checkbox
v-model="application.enabled"
data-test-id="checkbox-enabled"
:labels="checkboxLabel"
switch
/>
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:id="application.applicationID"
:resource="application"
/>
<!--
include hidden input to enable
trigger submit event w/ ENTER
-->
<input
type="submit"
class="d-none"
:disabled="saveDisabled"
>
</b-form>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<template #footer>
<c-input-confirm
v-if="application && application.applicationID && application.canDeleteApplication"
:data-test-id="deleteButtonStatusCypressId"
:text="getDeleteStatus"
variant="danger"
size="md"
@confirmed="$emit('delete')"
/>
<c-button-submit
:disabled="saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', application)"
/>
</template>
</b-card>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
export default {
name: 'CApplicationEditorInfo',
i18nOptions: {
namespaces: 'system.applications',
keyPrefix: 'editor.info',
},
props: {
application: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
data () {
return {
checkboxLabel: {
on: this.$t('general:label.general.yes'),
off: this.$t('general:label.general.no'),
},
}
},
computed: {
fresh () {
return !this.application.applicationID || this.application.applicationID === NoID
},
editable () {
return this.fresh ? this.canCreate : this.application.canUpdateApplication
},
saveDisabled () {
return !this.editable || [this.nameState].includes(false)
},
nameState () {
const { name } = this.application
return name ? null : false
},
getDeleteStatus () {
return this.application.deletedAt ? this.$t('undelete') : this.$t('delete')
},
deleteButtonStatusCypressId () {
return `button-${this.getDeleteStatus.toLowerCase()}`
},
},
}
</script>

View File

@@ -0,0 +1,54 @@
import { default as component } from './CApplicationEditorUnify.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
application: {},
unify: {
config: '"data"',
listed: true,
logo: '/applications/low-code-platform.png',
pinned: false,
name: 'Namespaces',
url: '/compose',
},
canPin: true,
processing: false,
success: false,
canCreate: true,
}
export default {
name: 'Editor unify',
group: ['Applications'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
unify: {
...props.unify,
listed: false,
name: '',
config: '',
},
canPin: false,
canCreate: false,
},
},
],
}

View File

@@ -0,0 +1,279 @@
<template>
<b-card
data-test-id="card-application-selector"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<b-form
@submit.prevent="$emit('submit', unify)"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('name.label')"
:description="$t('name.description')"
label-class="text-primary"
>
<b-form-input
v-model="unify.name"
data-test-id="input-name"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('logo.label')"
:description="$t('logo.description')"
label-class="text-primary"
>
<template #label>
<div
class="d-flex align-items-center"
>
{{ $t('logo.label') }}
<b-button
v-if="showLogoPreview"
v-b-modal.logo
data-test-id="button-logo-show"
variant="link"
class="d-flex align-items-center border-0 p-0 ml-2"
>
<font-awesome-icon
:icon="['fas', 'eye']"
/>
</b-button>
<b-button
v-if="showLogoPreview"
data-test-id="button-logo-reset"
variant="light"
size="sm"
class="py-0 ml-2"
@click="resetLogo()"
>
{{ $t('logo.reset') }}
</b-button>
</div>
</template>
<b-form-file
v-model="unifyAssets.logo"
data-test-id="file-logo-upload"
accept="image/*"
:placeholder="$t('logo.placeholder')"
@change="$emit('change-detected')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('url.label')"
:description="$t('url.description')"
label-class="text-primary"
>
<b-form-input
v-model="unify.url"
data-test-id="input-url"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('listed')"
label-class="text-primary"
>
<c-input-checkbox
v-model="unify.listed"
data-test-id="checkbox-listed"
switch
:labels="checkboxLabel"
/>
</b-form-group>
</b-col>
<b-col
v-if="canPin"
cols="12"
lg="6"
>
<b-form-group
:label="$t('pinned')"
label-class="text-primary"
>
<c-input-checkbox
v-model="unify.pinned"
data-test-id="checkbox-pinned"
switch
:labels="checkboxLabel"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
>
<b-form-group
:label="$t('config.label')"
:description="$t('config.description')"
label-class="text-primary"
>
<b-form-textarea
v-model="unify.config"
data-test-id="textarea-config"
:state="configState"
rows="10"
/>
</b-form-group>
</b-col>
</b-row>
</b-form>
<template #header>
<h4
data-test-id="card-title"
class="m-0"
>
{{ $t('title') }}
</h4>
</template>
<template #footer>
<c-button-submit
:disabled="disabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', { unify, unifyAssets })"
/>
</template>
<b-modal
id="logo"
hide-header
hide-footer
centered
body-class="p-1"
>
<b-img
data-test-id="img-logo-preview"
:src="unify.logo"
fluid-grow
/>
</b-modal>
</b-card>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
export default {
name: 'CApplicationEditorUnify',
i18nOptions: {
namespaces: 'system.applications',
keyPrefix: 'editor.unify',
},
props: {
unify: {
type: Object,
required: true,
},
application: {
type: Object,
required: true,
},
canPin: {
type: Boolean,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
},
data () {
return {
unifyAssets: {
icon: undefined,
logo: undefined,
},
checkboxLabel: {
on: this.$t('general:label.general.yes'),
off: this.$t('general:label.general.no'),
},
}
},
computed: {
disabled () {
return this.validConfig === false
},
validConfig () {
if (!this.unify) {
return null
}
try {
if ((this.unify.config || '').trim() !== '') {
JSON.parse(this.unify.config)
}
return null
} catch (e) {
return false
}
},
configState () {
if (((this.unify || {}).config || '').trim() === '') {
return null
} else {
return this.validConfig
}
},
showLogoPreview () {
return this.unify.logoID !== NoID
},
},
created () {
this.unify.name = this.unify.name ? this.unify.name : this.application.name
},
methods: {
resetLogo () {
this.unify.logo = undefined
this.unify.logoID = NoID
},
},
}
</script>

View File

@@ -0,0 +1,53 @@
import { default as component } from './CAuthclientEditorInfo.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
authclient: {
authClientID: '',
handle: 'corteza-webapp',
meta: {
description: '',
name: 'Corteza Web Applications',
},
},
roles: [],
processing: false,
success: false,
}
export default {
name: 'Editor info',
group: ['AuthClient'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
checkbox('Enable delete', {
value (p) { return p.authclient.authClientID.length > 0 },
update (p, val) { p.authclient.authClientID = val ? '123456789' : '' },
}),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
authclient: {
...props.authclient,
handle: '',
meta: {
name: '',
},
},
},
},
],
}

View File

@@ -0,0 +1,729 @@
<template>
<b-card
v-if="resource"
data-test-id="card-auth-client-info"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm auth-clients"
>
<b-form
@submit.prevent="submit"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('name')"
label-class="text-primary"
>
<b-form-input
v-model="resource.meta.name"
data-test-id="input-name"
required
:state="nameState"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('handle.label')"
label-class="text-primary"
>
<b-form-input
v-model="resource.handle"
data-test-id="input-handle"
:disabled="resource.isDefault"
:placeholder="$t('handle.placeholder-handle')"
:state="handleState"
/>
<b-form-invalid-feedback
data-test-id="feedback-invalid-handle"
:state="handleState"
>
{{ $t('handle.invalid-handle-characters') }}
</b-form-invalid-feedback>
<template
v-if="resource.isDefault"
#description
>
{{ $t('handle.disabledFootnote') }}
</template>
</b-form-group>
</b-col>
<b-col cols="12">
<b-form-group
:label="$t('redirectURI')"
label-class="text-primary"
>
<c-form-table-wrapper
:labels="{ addButton: $t('general:label.add') }"
test-id="button-add-redirect-uris"
@add-item="redirectURI.push('')"
>
<div
v-if="redirectURI.length"
>
<b-input-group
v-for="(value, index) in redirectURI"
:key="index"
class="mb-2"
>
<b-form-input
v-model="redirectURI[index]"
data-test-id="input-uri"
:placeholder="$t('uri')"
/>
<b-button
data-test-id="button-remove-uri"
class="ml-1 text-danger"
variant="link"
@click="redirectURI.splice(index, 1)"
>
<font-awesome-icon
:icon="['fas', 'times']"
/>
</b-button>
</b-input-group>
</div>
</c-form-table-wrapper>
</b-form-group>
</b-col>
<b-col cols="12">
<b-form-group
v-if="!fresh"
:label="$t('secret')"
label-class="text-primary"
>
<template #label>
{{ $t('secret') }}
<b-button
v-if="!secretVisible"
v-b-tooltip.noninteractive.hover="{ title: $t('tooltip.show-client-secret'), boundary: 'body' }"
data-test-id="button-show-client-secret"
variant="outline-light"
size="sm"
class="text-secondary border-0"
@click="showSecret()"
>
<font-awesome-icon
:icon="['fas', 'eye']"
/>
</b-button>
<b-button
v-else
v-b-tooltip.noninteractive.hover="{ title: $t('tooltip.hide-client-secret'), boundary: 'body' }"
data-test-id="button-hide-client-secret"
variant="outline-light"
size="sm"
class="text-secondary border-0"
@click="hideSecret()"
>
<font-awesome-icon
:icon="['fas', 'eye-slash']"
/>
</b-button>
</template>
<b-input-group>
<b-form-input
v-model="secret"
data-test-id="input-client-secret"
disabled
placeholder="****************************************************************"
/>
<b-button
v-b-tooltip.noninteractive.hover="{ title: $t('tooltip.regenerate-secret'), boundary: 'body' }"
data-test-id="button-regenerate-client-secret"
variant="outline-light"
class="text-secondary border-0"
@click="regenerateSecret()"
>
<font-awesome-icon
:icon="['fas', 'sync']"
/>
</b-button>
</b-input-group>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group>
<b-form-radio-group
v-model="resource.validGrant"
value="authorization_code"
:options="[
{ value: 'authorization_code', text: $t('grant.authorization_code') },
{ value: 'client_credentials', text: $t('grant.client_credentials') },
]"
@change="onGrantChange"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group>
<b-form-checkbox
data-test-id="checkbox-allow-access-to-user-profile"
:checked="((resource.scope || []).includes('profile'))"
@change="setScope($event, 'profile')"
>
{{ $t('profile') }}
</b-form-checkbox>
<b-form-checkbox
data-test-id="checkbox-allow-access-to-corteza-api"
:checked="((resource.scope || []).includes('api'))"
@change="setScope($event, 'api')"
>
{{ $t('api') }}
</b-form-checkbox>
<b-form-checkbox
data-test-id="checkbox-allow-client-to-use-oidc"
:checked="((resource.scope || []).includes('openid'))"
@change="setScope($event, 'openid')"
>
{{ $t('openid') }}
</b-form-checkbox>
<b-form-checkbox
v-if="discoveryEnabled"
data-test-id="checkbox-allow-client-access-to-discovery"
:checked="((resource.scope || []).includes('discovery'))"
@change="setScope($event, 'discovery')"
>
{{ $t('discovery') }}
</b-form-checkbox>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
data-test-id="valid-from"
:label="$t('validFrom.label')"
:description="$t('validFrom.description')"
label-class="text-primary"
>
<c-input-date-time
v-model="resource.validFrom"
data-test-id="input-valid-from"
:labels="{
clear: $t('general:label.clear'),
none: $t('general:label.none'),
now: $t('general:label.now'),
today: $t('general:label.today'),
}"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
data-test-id="expires-at"
:label="$t('expiresAt.label')"
:description="$t('expiresAt.description')"
label-class="text-primary"
>
<c-input-date-time
v-model="resource.expiresAt"
data-test-id="input-expires-at"
:labels="{
clear: $t('general:label.clear'),
none: $t('general:label.none'),
now: $t('general:label.now'),
today: $t('general:label.today'),
}"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group>
<b-form-checkbox
v-model="resource.enabled"
data-test-id="checkbox-is-client-enabled"
:disabled="resource.isDefault"
>
{{ $t('enabled.label') }}
</b-form-checkbox>
<b-form-text v-if="resource.isDefault">
{{ $t('enabled.disabledFootnote') }}
</b-form-text>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group>
<b-form-checkbox
v-model="resource.trusted"
data-test-id="checkbox-is-client-trusted"
>
{{ $t('trusted.label') }}
</b-form-checkbox>
<b-form-text>{{ $t('trusted.description') }}</b-form-text>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
v-show="isClientCredentialsGrant"
cols="12"
lg="6"
>
<b-form-group
data-test-id="impersonate-user"
:label="$t('security.impersonateUser.label')"
:description="$t('security.impersonateUser.description')"
label-class="text-primary"
>
<c-input-user
v-model="resource.security.impersonateUser"
:placeholder="$t('security.impersonateUser.placeholder')"
:clearable="true"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
data-test-id="permitted-roles"
:label="$t('security.permittedRoles.label')"
label-class="text-primary"
>
<c-role-picker
v-model="resource.security.permittedRoles"
>
<template #description>
{{ $t('security.permittedRoles.description') }}
</template>
</c-role-picker>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('security.prohibitedRoles.label')"
data-test-id="prohibited-roles"
label-class="text-primary"
>
<c-role-picker
v-model="resource.security.prohibitedRoles"
>
<template #description>
{{ $t('security.prohibitedRoles.description') }}
</template>
</c-role-picker>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
data-test-id="forced-roles"
:label="$t('security.forcedRoles.label')"
label-class="text-primary"
>
<c-role-picker
v-model="resource.security.forcedRoles"
class="mb-3"
>
<template #description>
{{ $t('security.forcedRoles.description') }}
</template>
</c-role-picker>
</b-form-group>
</b-col>
<b-col
v-if="!fresh && isClientCredentialsGrant"
cols="12"
>
<b-form-group label-class="text-primary">
<template #label>
{{ $t('cUrl') }}
<b-button
v-b-tooltip.noninteractive.hover="{ title: $t('tooltip.copy-cURL'), boundary: 'body' }"
data-test-id="button-copy-cURL"
variant="outline-light"
size="sm"
class="text-secondary border-0"
@click="copyToClipboard(exampleCurl)"
>
<font-awesome-icon
:icon="['far', 'copy']"
/>
</b-button>
</template>
<b-textarea
:value="exampleCurl"
disabled
data-test-id="cURL-string"
rows="3"
class="mb-2"
/>
</b-form-group>
<b-form-group label-class="text-primary">
<template #label>
{{ $t('accessToken') }}
<b-button
v-if="tokenRequest.token"
v-b-tooltip.noninteractive.hover="{ title: $t('tooltip.copy-access-token'), boundary: 'body' }"
data-test-id="copy-token-from-request"
variant="outline-light"
size="sm"
class="text-secondary border-0"
@click="copyToClipboard(tokenRequest.token)"
>
<font-awesome-icon
:icon="['far', 'copy']"
/>
</b-button>
</template>
<b-textarea
v-if="tokenRequest.token"
data-test-id="cURL-string"
:value="tokenRequest.token"
disabled
rows="5"
/>
<b-button
v-else
data-test-id="button-test-cURL"
variant="light"
@click="getAccessTokenAPI()"
>
{{ $t('generateAccessToken') }}
</b-button>
<p
v-if="tokenRequest.error"
class="text-danger mt-2"
>
{{ tokenRequest.error }}
</p>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('security.defaultUserGroup.label')"
label-class="text-primary"
>
<c-input-user-group
v-model="resource.security.userGroup"
:placeholder="$t('security.defaultUserGroup.placeholder')"
/>
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:resource="resource"
/>
<!--
include hidden input to enable
trigger submit event w/ ENTER
-->
<input
data-test-id="button-submit"
type="submit"
class="d-none"
:disabled="saveDisabled"
>
</b-form>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<template #footer>
<template
v-if="canDelete"
>
<c-input-confirm
:data-test-id="isDeleted ? 'button-undelete': 'button-delete'"
:disabled="processing"
:text="isDeleted ? $t('undelete') : $t('delete')"
variant="danger"
size="md"
@confirmed="$emit(isDeleted ? 'undelete' : 'delete', resource.authClientID)"
/>
</template>
<c-button-submit
:disabled="saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="submit"
/>
</template>
</b-card>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
import { handle, components } from '@cortezaproject/corteza-vue'
import CRolePicker from 'corteza-webapp-admin/src/components/CRolePicker'
import copy from 'copy-to-clipboard'
import axios from 'axios'
const { CInputDateTime, CInputUser, CInputUserGroup } = components
export default {
name: 'CAuthclientEditorInfo',
i18nOptions: {
namespaces: 'system.authclients',
keyPrefix: 'editor.info',
},
components: {
CRolePicker,
CInputUser,
CInputDateTime,
CInputUserGroup,
},
props: {
resource: {
type: Object,
required: true,
},
canDelete: {
type: Boolean,
default: () => false,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
data () {
return {
requestedSecret: '',
secret: '',
redirectURI: this.resource.redirectURI ? this.resource.redirectURI.split(' ') : [],
curlVisible: false,
tokenRequest: {
token: '',
error: '',
},
checkboxLabel: {
on: this.$t('general:label.general.yes'),
off: this.$t('general:label.general.no'),
},
}
},
computed: {
fresh () {
return !this.resource.authClientID || this.resource.authClientID === NoID
},
editable () {
return this.fresh ? this.canCreate : this.resource.canUpdateAuthClient
},
isDeleted () {
return this.resource.deletedAt && this.resource.canDeleteAuthClient
},
secretVisible () {
return this.secret.length > 0
},
nameState () {
return this.resource.meta.name ? null : false
},
handleState () {
return handle.handleState(this.resource.handle)
},
isClientCredentialsGrant () {
return this.resource.validGrant === 'client_credentials'
},
discoveryEnabled () {
return this.$Settings.get('discovery.enabled', false)
},
saveDisabled () {
return !this.editable || [this.nameState, this.handleState].includes(false)
},
curlURL () {
return this.$auth.cortezaAuthURL + '/oauth2/token'
},
exampleCurl () {
return `curl -X POST ${this.curlURL} -d grant_type=${this.resource.validGrant} -d scope='${this.resource.scope}' -u ${this.resource.authClientID}:${this.secret || 'PLACE-YOUR-CLIENT-SECRET-HERE'}`
},
},
watch: {
redirectURI: {
handler (redirectURI) {
this.resource.redirectURI = redirectURI.filter(ru => ru).join(' ')
},
},
},
methods: {
onGrantChange (grant) {
if (grant === 'client_credentials' && (!this.resource.security.impersonateUser || this.resource.security.impersonateUser === NoID)) {
this.resource.security.impersonateUser = this.$auth.user.userID
}
},
copyToClipboard (value) {
copy(value)
},
toggleCurlSnippet () {
this.curlVisible = !this.curlVisible
},
submit () {
if (!this.isClientCredentialsGrant || !this.resource.security.impersonateUser) {
this.resource.security.impersonateUser = NoID
}
this.$emit('submit', this.resource)
},
setScope (value, target) {
let items = this.resource.scope ? this.resource.scope.split(' ') : []
if (value) {
items.push(target)
} else {
items = items.filter(i => i !== target)
}
this.resource.scope = items.join(' ')
},
requestSecret () {
const clientID = this.resource.authClientID
return this.$SystemAPI.authClientExposeSecret(({ clientID })).then(secret => {
this.requestedSecret = secret
})
},
async showSecret () {
if (!this.requestedSecret) {
await this.requestSecret()
}
this.secret = this.requestedSecret
},
hideSecret () {
this.secret = ''
},
async regenerateSecret () {
const clientID = this.resource.authClientID
this.$SystemAPI.authClientRegenerateSecret(({ clientID })).then(secret => {
this.requestedSecret = secret
})
},
async getAccessTokenAPI () {
const clientID = this.resource.authClientID
if (!this.requestedSecret) {
await this.requestSecret()
}
const params = new URLSearchParams()
params.append('grant_type', this.resource.validGrant)
params.append('scope', this.resource.scope)
axios.post(this.curlURL, params, { auth: { username: clientID, password: this.requestedSecret } }).then(response => {
this.tokenRequest.token = (response.data || {}).access_token
this.tokenRequest.error = ''
}).catch(e => {
const { error } = e.response.data || {}
this.tokenRequest.error = error
this.tokenRequest.token = ''
})
},
},
}
</script>

View File

@@ -0,0 +1,27 @@
import * as applications from './Application/C3'
import * as role from './Role/C3'
import * as settings from './Settings/C3'
import { default as CAuthclientEditorInfo } from './Authclient/C3'
import { default as CFederationEditorInfo } from './Federation/C3'
import { default as CQueueEditorInfo } from './Queues/C3'
import { default as CTemplateEditorInfo } from './Template/C3'
import { default as CUserEditorInfo } from './User/C3'
import { default as CWorkflowEditorInfo } from './Workflow/C3'
import { default as CResourceListStatusFilter } from './CResourceListStatusFilter.c3'
import { default as CSubmitButton } from './CSubmitButton.c3'
export default {
...applications,
...role,
...settings,
CAuthclientEditorInfo,
CFederationEditorInfo,
CQueueEditorInfo,
CTemplateEditorInfo,
CUserEditorInfo,
CWorkflowEditorInfo,
CResourceListStatusFilter,
CSubmitButton,
}

View File

@@ -0,0 +1,24 @@
<template>
<div
class="d-flex justify-content-end flex-wrap gap-1 mb-2 flex-fill-child"
>
<portal to="topbar-title">
{{ title }}
</portal>
<slot />
</div>
</template>
<script>
export default {
name: 'CContentHeader',
props: {
title: {
type: String,
required: false,
default: '',
},
},
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<div>
<b-button
v-if="label || editable"
:variant="label ? 'link' : 'primary'"
rounded
:class="label ? 'p-0 border-0' : ''"
@click="openMap"
>
<span v-if="label">
{{ label }}
</span>
<span v-else>
<font-awesome-icon
:icon="['fas', 'map-marked-alt']"
/>
{{ $t('openMap') }}
</span>
</b-button>
<b-modal
v-model="map.show"
size="xl"
title="Map"
body-class="p-0"
hide-header
hide-footer
>
<c-map
:map="map"
:markers="[{ value }]"
style="height: 75vh; width: 100%;"
@on-marker-click="removeMarker"
@on-map-click="placeMarker"
/>
</b-modal>
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CMap } = components
export default {
i18nOptions: {
namespaces: 'general.label',
},
components: {
CMap,
},
props: {
value: {
type: Array,
required: true,
},
editable: {
type: Boolean,
default: false,
},
label: {
type: String,
default: '',
},
},
data () {
return {
map: {
show: false,
zoom: 3,
center: [30, 30],
rotation: 0,
attribution: '&copy; <a target="_blank" rel="noopener noreferrer" href="http://osm.org/copyright">OpenStreetMap</a>',
},
}
},
methods: {
openMap () {
this.map.show = true
},
placeMarker ({ latlng = {} }) {
const { lat = 0, lng = 0 } = latlng
this.$emit('input', [lat, lng])
},
removeMarker () {
this.$emit('input', [])
},
},
}
</script>
<style lang="scss">
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div
data-test-id="member-picker"
class="d-flex flex-column"
>
<c-input-user
data-test-id="input-member-picker"
:selectable="r => !value.includes(r.userID)"
:placeholder="$t('admin:picker.member.placeholder')"
clear-on-select
@input-object="addUser"
/>
<b-spinner
v-if="preloading"
class="mx-auto my-4"
/>
<b-table-simple
v-else-if="getSelectedUsers.length"
responsive
small
hover
class="w-100 p-0 mb-0 mt-1"
>
<tbody>
<tr
v-for="user in getSelectedUsers"
:key="user.userID"
data-test-id="selected-row-list"
>
<td class="align-middle">
<c-user-label :user="user" />
</td>
<td
v-if="!noRemove"
class="text-right"
>
<c-input-confirm
data-test-id="button-remove-user"
show-icon
@confirmed="removeUser(user.userID)"
/>
</td>
</tr>
</tbody>
</b-table-simple>
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CInputUser, CUserLabel } = components
export default {
components: {
CInputUser,
CUserLabel,
},
props: {
// list of user IDs
value: {
type: Array,
default: () => ([]),
},
noRemove: {
type: Boolean,
required: false,
default: false,
},
},
data () {
return {
fetching: false,
preloading: false,
filter: '',
selectedUsers: [],
}
},
computed: {
getSelectedUsers () {
return this.selectedUsers.filter(({ userID }) => this.value.includes(userID))
},
},
mounted () {
this.preloadSelected()
},
methods: {
addUser (user) {
if (!this.value.includes(user.userID)) {
this.selectedUsers.push(user)
this.$emit('input', [...this.value, user.userID])
}
},
removeUser (userID) {
this.selectedUsers = this.selectedUsers.filter(({ userID: rID }) => rID !== userID)
this.$emit('input', this.value.filter(v => v !== userID))
},
preloadSelected () {
if (!this.value.length) {
return
}
this.preloading = true
return this.$SystemAPI.userList({ userID: this.value, suspended: 1, deleted: 1 })
.then(({ set }) => {
this.selectedUsers = set || []
})
.finally(() => {
this.preloading = false
})
.catch(this.toastErrorHandler(this.$t('notification:user.fetch.error')))
},
},
}
</script>

View File

@@ -0,0 +1,28 @@
import { default as component } from './CResourceListStatusFilter'
import { components } from '@cortezaproject/corteza-vue'
const { input, select } = components.C3.controls
const options = [
{ value: '0', text: 'Excluded' },
{ value: '1', text: 'Inclusive' },
{ value: '2', text: 'Exclusive' },
]
const controls = [
[
input('Label', 'label'),
select('Filter', 'value', options),
],
[
input('Excluded label', 'excluded-label'),
input('Inclusive label', 'inclusive-label'),
input('Exclusive label', 'exclusive-label'),
],
]
export default {
name: 'Resource list status filter',
group: ['Root components'],
component,
controls,
}

View File

@@ -0,0 +1,27 @@
<template>
<b-col class="d-flex align-items-center flex-wrap gap-1">
<b-form-radio-group
v-model="$attrs.value"
v-bind="$attrs"
:options="[
{ value: 0, text: $attrs['excluded-label'] || 'excluded' },
{ value: 1, text: $attrs['inclusive-label'] || 'inclusive' },
{ value: 2, text: $attrs['exclusive-label'] || 'exclusive' }
]"
buttons
button-variant="outline-primary"
size="sm"
name="radio-btn-outline"
v-on="$listeners"
/>
<span class="text-nowrap">
{{ $attrs.label }}
</span>
</b-col>
</template>
<script>
export default {
name: 'CResourceListStatusFilter',
}
</script>

View File

@@ -0,0 +1,139 @@
<template>
<div
data-test-id="role-picker"
class="d-flex flex-column"
>
<c-input-role
data-test-id="input-role-picker"
:selectable="r => !value.includes(r.roleID)"
:placeholder="$t('admin:picker.role.placeholder')"
:visible="isRoleVisible"
clear-on-select
@input="addRole($event)"
/>
<b-spinner
v-if="preloading"
class="mx-auto my-4"
/>
<b-table-simple
v-else-if="getSelectedRoles.length"
responsive
small
hover
class="w-100 p-0 mb-0 mt-1"
>
<tbody>
<tr
v-for="role in getSelectedRoles"
:key="role.roleID"
data-test-id="selected-row-list"
>
<td class="align-middle">
{{ getRoleLabel(role) }}
</td>
<td class="text-right">
<c-input-confirm
data-test-id="button-remove-role"
show-icon
@confirmed="removeRole(role.roleID)"
/>
</td>
</tr>
</tbody>
</b-table-simple>
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CInputRole } = components
export default {
components: {
CInputRole,
},
props: {
// list of role IDs
value: {
type: Array,
default: () => ([]),
},
},
data () {
return {
fetching: false,
preloading: false,
filter: '',
selectedRoles: [],
}
},
computed: {
getSelectedRoles () {
return this.selectedRoles.filter(({ roleID }) => this.value.includes(roleID))
},
},
mounted () {
this.preloadSelected()
},
methods: {
addRole (role) {
if (!this.value.includes(role.roleID)) {
this.selectedRoles.push(role)
this.$emit('input', [...this.value, role.roleID])
}
},
removeRole (roleID) {
this.selectedRoles = this.selectedRoles.filter(({ roleID: rID }) => rID !== roleID)
this.$emit('input', this.value.filter(v => v !== roleID))
},
preloadSelected () {
if (!this.value.length) {
return
}
this.preloading = true
return this.$SystemAPI.roleList({ roleID: this.value })
.then(({ set }) => {
this.selectedRoles = set || []
})
.finally(() => {
this.preloading = false
})
.catch(this.toastErrorHandler(this.$t('notification:role.fetch.error')))
},
getRoleLabel ({ name, handle, roleID }) {
return name || handle || roleID
},
isRoleVisible ({ isClosed, meta = {} }) {
return !(isClosed || (meta.context && meta.context.resourceTypes))
},
},
}
</script>
<style lang="scss">
.results {
z-index: 100;
.filtered-role {
cursor: pointer;
&:hover {
background-color: var(--light);
}
}
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<b-row>
<b-col
v-if="showDivider"
cols="12"
>
<hr>
</b-col>
<b-col
v-if="hasID"
cols="12"
>
<b-form-group
:label="$t('id')"
label-class="text-primary"
>
{{ id }}
</b-form-group>
</b-col>
<b-col
v-for="(f, i) in systemFields"
:key="i"
cols="12"
>
<b-form-group
v-if="getFieldValue(f) !== '0'"
:label="$t(`general:system.field.${f}`)"
label-class="text-primary"
:data-test-id="`input-${generateTestID(f)}`"
>
<c-user-label
v-if="isUserField(f)"
:user="getUserForField(f)"
/>
<template v-else>
{{ getFieldValue(f) }}
</template>
</b-form-group>
</b-col>
<slot name="custom-field" />
</b-row>
</template>
<script>
import Vue from 'vue'
import { components } from '@cortezaproject/corteza-vue'
import { getSystemFields, kebabize, userFields } from 'corteza-webapp-admin/src/lib/sysFields'
const { CUserLabel } = components
const userCache = new Map()
const inflight = new Map()
export default {
name: 'CSystemFields',
components: {
CUserLabel,
},
props: {
resource: {
type: Object,
required: true,
},
id: {
type: String,
default: '',
},
},
data () {
return {
resolvedUsers: {},
}
},
computed: {
systemFields () {
return getSystemFields(this.resource)
},
hasID () {
return this.id && this.id !== '0'
},
hasVisibleSystemFields () {
return this.systemFields.some(f => this.getFieldValue(f) !== '0')
},
showDivider () {
return this.hasID || this.hasVisibleSystemFields
},
},
watch: {
resource: {
immediate: true,
deep: true,
handler () {
userFields.forEach(f => {
const userID = this.resource[f]
if (userID && userID !== '0') {
this.resolveUser(userID)
}
})
},
},
},
methods: {
generateTestID (field) {
return kebabize(field)
},
isUserField (field) {
return userFields.includes(field)
},
getFieldValue (field) {
const isTimeValue = field.substring(field.length - 2) === 'At'
return isTimeValue ? this.$options.filters.locFullDateTime(this.resource[field]) : this.resource[field]
},
getUserForField (field) {
const userID = this.resource[field]
if (!userID || userID === '0') return {}
return this.resolvedUsers[userID] || { userID }
},
resolveUser (userID) {
if (this.resolvedUsers[userID]) return
if (userCache.has(userID)) {
Vue.set(this.resolvedUsers, userID, userCache.get(userID))
return
}
const pending = inflight.get(userID) || this.$SystemAPI.userRead({ userID })
.then(user => {
userCache.set(userID, user)
return user
})
.finally(() => {
inflight.delete(userID)
})
inflight.set(userID, pending)
pending.then(user => {
Vue.set(this.resolvedUsers, userID, user)
}).catch(() => {
Vue.set(this.resolvedUsers, userID, { userID })
})
},
},
}
</script>

View File

@@ -0,0 +1,330 @@
<template>
<div
class="overflow-auto py-3"
>
<b-list-group
v-for="(grp, g) in navigation"
:key="g"
tag="li"
>
<h6
v-if="grp.header"
class="mt-3 text-uppercase"
>
{{ $t(grp.header.label) }}
</h6>
<c-sidebar-nav-items
:items="grp.items"
default-route-name="dashboard"
class="overflow-auto h-100"
/>
</b-list-group>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { components } from '@cortezaproject/corteza-vue'
const { CSidebarNavItems } = components
export default {
components: {
CSidebarNavItems,
},
i18nOptions: {
namespaces: 'navigation',
},
data () {
return {
nav: [
{
items: [
{
label: 'dashboard',
route: 'dashboard',
icon: 'tachometer-alt',
},
],
},
{
header: { label: 'system.group' },
items: [
{
label: 'system.items.users',
route: 'system.user',
icon: 'users',
can: [
['system/', 'users.search'],
['system/', 'user.create'],
],
},
{
label: 'system.items.roles',
route: 'system.role',
icon: 'hat-cowboy',
can: [
['system/', 'roles.search'],
['system/', 'role.create'],
],
},
{
label: 'system.items.usergroups',
route: 'system.userGroup',
icon: 'user-group',
can: ['system/', 'user-groups.search'],
},
{
label: 'system.items.applications',
route: 'system.application',
icon: 'th-large',
can: [
['system/', 'applications.search'],
['system/', 'application.create'],
],
},
{
label: 'system.items.templates',
route: 'system.template',
icon: 'file-code',
can: ['system/', 'templates.search'],
},
{
label: 'system.items.settings',
route: 'system.settings',
icon: 'sliders-h',
can: ['system/', 'settings.read'],
},
{
label: 'system.items.email',
route: 'system.email',
icon: 'envelope-open',
// all email management is handled
// via settings for now
can: [
['system/', 'settings.read'],
['system/', 'settings.manage'],
],
},
{
label: 'system.items.authclients',
route: 'system.authClient',
icon: 'key',
can: [
['system/', 'auth-clients.search'],
['system/', 'auth-client.create'],
],
},
{
label: 'system.items.actionlog',
route: 'system.actionlog',
icon: 'glasses',
can: ['system/', 'action-log.read'],
},
{
label: 'system.items.queues',
route: 'system.queue',
icon: 'stream',
can: [
['system/', 'queues.search'],
['system/', 'queue.create'],
],
},
{
label: 'system.items.apigw',
route: 'system.apigw',
icon: 'archway',
can: [
['system/', 'apigw-routes.search'],
['system/', 'apigw-route.create'],
],
},
{
label: 'system.items.connections',
route: 'system.connection',
icon: 'cloud',
can: [
['system/', 'dal-connections.search'],
['system/', 'dal-connection.create'],
],
},
{
label: 'system.items.code-snippets',
route: 'system.codesnippets',
icon: 'file-code',
can: [
['system/', 'settings.read'],
['system/', 'settings.manage'],
],
},
{
label: 'system.items.sensitivityLevel',
route: 'system.sensitivityLevel',
icon: 'stamp',
can: ['system/', 'dal-sensitivity-level.manage'],
},
{
label: 'system.items.permissions',
route: 'system.permissions',
icon: 'lock',
can: ['system/', 'grant'],
},
],
},
{
header: { label: 'compose.group' },
items: [
{
label: 'compose.items.settings',
route: 'compose.settings',
icon: 'sliders-h',
can: [
['compose/', 'settings.read'],
['compose/', 'settings.manage'],
],
},
{
label: 'compose.items.permissions',
route: 'compose.permissions',
icon: 'lock',
can: ['compose/', 'grant'],
},
],
},
{
header: { label: 'automation.group' },
items: [
{
label: 'automation.items.workflows',
route: 'automation.workflow',
icon: 'project-diagram',
can: [
['automation/', 'workflows.search'],
['automation/', 'workflow.create'],
],
},
{
label: 'automation.items.sessions',
route: 'automation.session',
icon: 'business-time',
can: ['automation/', 'sessions.search'],
},
{
label: 'automation.items.scripts',
route: 'automation.scripts',
icon: 'scroll',
can: ['automation/', 'workflows.search'],
},
{
label: 'automation.items.permissions',
route: 'automation.permissions',
icon: 'lock',
can: ['automation/', 'grant'],
},
],
},
{
header: { label: 'federation.group' },
items: [
{
label: 'federation.items.nodes',
route: 'federation.nodes',
icon: 'share-alt',
can: ['federation/', 'pair'],
},
{
label: 'federation.items.permissions',
route: 'federation.permissions',
icon: 'lock',
can: ['federation/', 'grant'],
},
],
},
{
header: { label: 'ui.group' },
items: [
{
label: 'ui.items.theming',
route: 'theming.settings',
icon: 'palette',
can: [
['system/', 'settings.read'],
['system/', 'settings.manage'],
],
},
{
label: 'ui.items.navigation',
route: 'navigation.settings',
icon: 'bars',
can: [
['system/', 'settings.read'],
['system/', 'settings.manage'],
],
},
{
label: 'ui.items.location',
route: 'location.settings',
icon: 'map-marker-alt',
can: [
['system/', 'settings.read'],
['system/', 'settings.manage'],
],
},
],
},
],
}
},
computed: {
...mapGetters({
can: 'rbac/can',
}),
navigation () {
return this.nav.map(grp => {
grp = JSON.parse(JSON.stringify(grp))
grp.items = grp.items
.map(itm => {
const page = {
name: itm.route,
title: this.$t(itm.label),
icon: ['fas', itm.icon],
}
if (!itm.can) {
// if not explicitly set, allow
itm.can = true
}
if (Array.isArray(itm.can)) {
// if array then call the perm checking function
if (Array.isArray(itm.can[0])) {
itm.can = itm.can.every(c => this.can(...c))
} else {
itm.can = this.can(...itm.can)
}
}
return { page, can: itm.can }
})
.filter(({ can }) => can)
if (grp.items.length === 0) {
return null
}
return grp
}).filter(i => i)
},
},
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<b-form
@submit.prevent="$emit('upload')"
>
<c-uploader
v-if="!disabled"
:endpoint="uploadEndpoint"
:labels="{
placeholder: $t('general:label.dropFiles'),
uploading: $t('general:label.uploading'),
fileTypeNotAllowed: $t('general:label.fileTypeNotAllowed'),
}"
:accepted-files="['image/*']"
@upload="$emit('upload', $event)"
/>
<div
v-if="value"
class="d-flex justify-content-center w-100 mt-2"
>
<b-img
:src="value"
class="mw-100 h-auto"
/>
</div>
</b-form>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CUploader } = components
export default {
name: 'CUploaderWithPreview',
components: {
CUploader,
},
props: {
value: {
type: String,
default: () => undefined,
},
disabled: {
type: Boolean,
default: () => false,
},
labels: {
type: Object,
default: () => ({}),
},
endpoint: {
type: String,
required: true,
},
acceptedFiles: {
type: Array,
default: () => [],
},
maxFilesize: {
type: Number,
default: 100,
},
},
data () {
return {
panels: [],
}
},
computed: {
uploadEndpoint () {
return this.$SystemAPI.baseURL + this.endpoint
},
},
}
</script>

View File

@@ -0,0 +1,179 @@
<template>
<b-card
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-row
v-if="issues.length"
>
<b-col cols="12">
<b-form-group
:label="$t('connectivity-issues')"
label-class="text-primary"
>
<b-alert
v-for="issue in issues"
:key="issue.issue"
show
variant="danger"
>
{{ issue.issue }}
</b-alert>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col cols="12">
<b-form-group
:label="$t('form.model-ident.label')"
:description="$t('form.model-ident.description', { interpolation: { prefix: '{{{', suffix: '}}}' } })"
label-class="text-primary"
>
<b-form-input
v-model="dal.modelIdent"
:disabled="disabled"
:placeholder="$t('form.model-ident.placeholder')"
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col cols="12">
<b-form-group
:label="$t('form.type.label')"
:description="$t('form.type.description')"
label-class="text-primary"
>
<b-form-input
v-model="dal.type"
:disabled="disabled"
:placeholder="$t('form.type.placeholder')"
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col cols="12">
<b-form-group
:label="$t('form.params.label')"
:description="$t('form.params.description')"
label-class="text-primary"
>
<b-form-textarea
v-model="paramsJson"
:disabled="disabled"
:class="paramsJsonEditorClass"
:placeholder="$t('form.params.placeholder')"
rows="5"
@blur="processParamsJSON"
/>
</b-form-group>
</b-col>
</b-row>
<template #footer>
<c-button-submit
:disabled="disabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit')"
/>
</template>
</b-card>
</template>
<script>
export default {
i18nOptions: {
namespaces: 'system.connections',
keyPrefix: 'editor.dal',
},
props: {
disabled: { type: Boolean, default: false },
dal: {
type: Object,
required: true,
},
issues: {
type: Array,
default: () => ([]),
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
},
data () {
return {
/**
* JSON string version of the connection params object
* used to display nicely formatted JSON in the textarea
*/
paramsJson: '',
// holds JSON validation errors
paramsJsonEditorClass: '',
}
},
watch: {
dal: {
handler: function (dal) {
this.paramsJson = JSON.stringify(dal.params || { dsn: '' }, null, 2)
},
deep: true,
immediate: true,
},
},
methods: {
/**
* Validates JSON string and sets errors if any
* In case of a valid JSON it will update the connection object
*/
processParamsJSON () {
this.paramsJsonEditorClass = ''
try {
// parse json string, ensure it's an object
const json = JSON.parse(this.paramsJson)
if (typeof json !== 'object') {
throw new Error('JSON is not an object')
}
// iterate through all properties and assign them to connection object
if (!this.dal.params) {
this.$set(this.dal, 'params', {})
}
for (const key in json) {
if (Object.prototype.hasOwnProperty.call(json, key)) {
this.$set(this.dal.params, key, json[key])
}
}
} catch (e) {
this.paramsJsonEditorClass = 'border-danger'
}
},
},
}
</script>

View File

@@ -0,0 +1,247 @@
<template>
<b-card
data-test-id="card-connection-settings"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('form.name.label')"
:description="$t('form.name.description')"
label-class="text-primary"
class="mb-3"
>
<b-form-input
v-model="connection.meta.name"
required
:placeholder="$t('form.name.placeholder')"
:state="nameState"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('form.handle.label')"
label-class="text-primary"
class="mb-3"
>
<b-form-input
v-model="connection.handle"
:disabled="isPrimary || disabled"
:placeholder="$t('form.handle.placeholder')"
:state="handleState"
/>
<b-form-invalid-feedback :state="handleState">
{{ $t('form.handle.invalid-characters') }}
</b-form-invalid-feedback>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('form.location-name.label')"
:description="$t('form.location-name.description')"
label-class="text-primary"
>
<b-form-input
v-model="connection.meta.location.properties.name"
:placeholder="$t('form.location-name.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('form.location-geometry.label')"
:description="$t('form.location-geometry.description')"
label-class="text-primary"
>
<c-location
v-if="!disabled"
v-model="locationCoordinates"
:label="locationCoordinatesLabel"
editable
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('form.ownership.label')"
:description="$t('form.ownership.description')"
label-class="text-primary"
class="mb-3"
>
<b-form-input
v-model="connection.meta.ownership"
:placeholder="$t('form.ownership.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('form.sensitivity-level.label')"
:description="$t('form.sensitivity-level.description')"
label-class="text-primary"
>
<c-sensitivity-level-picker
v-model="connection.config.privacy.sensitivityLevelID"
:options="sensitivityLevels"
:placeholder="$t('form.sensitivity-level.placeholder')"
/>
</b-form-group>
</b-col>
</b-row>
<template #footer>
<c-input-confirm
v-if="!fresh && !isPrimary && !disabled"
:text="connection.deletedAt ? $t('general:label.undelete') : $t('general:label.delete')"
variant="danger"
size="md"
@confirmed="$emit('delete')"
/>
<c-button-submit
:disabled="disabled || saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit')"
/>
</template>
</b-card>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
import { components, handle } from '@cortezaproject/corteza-vue'
import CLocation from 'corteza-webapp-admin/src/components/CLocation'
const { CSensitivityLevelPicker } = components
export default {
i18nOptions: {
namespaces: 'system.connections',
keyPrefix: 'editor.basic',
},
components: {
CLocation,
CSensitivityLevelPicker,
},
props: {
connection: {
type: Object,
required: true,
},
sensitivityLevels: {
type: Array,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
computed: {
isPrimary () {
return this.connection.type === 'corteza::system:primary-dal-connection'
},
fresh () {
return !this.connection.connectionID || this.connection.connectionID === NoID
},
editable () {
return this.fresh ? this.canCreate : true
},
nameState () {
return this.connection.meta.name ? null : false
},
handleState () {
return handle.handleState(this.connection.handle)
},
saveDisabled () {
return !this.editable || [this.nameState, this.handleState].includes(false)
},
locationCoordinates: {
get () {
const { coordinates = [] } = (this.connection.meta.location || {}).geometry || {}
return coordinates || []
},
set (value) {
if (!this.connection.meta.location) {
this.$set(this.connection.meta, 'location', { type: 'Feature', geometry: { type: 'Point', coordinates: [] }, properties: { name: '' } })
}
if (!this.connection.meta.location.geometry) {
this.$set(this.connection.meta.location, 'geometry', { type: 'Point', coordinates: [] })
}
this.connection.meta.location.geometry.coordinates = value
},
},
locationCoordinatesLabel () {
return (this.locationCoordinates || []).map(c => c?.toFixed?.(7) || c).join(', ')
},
},
}
</script>

View File

@@ -0,0 +1,98 @@
<template>
<b-card
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-row
v-for="prop in list"
:key="prop"
>
<b-col cols="12">
<b-form-checkbox
v-model="properties[prop].enabled"
class="mb-1"
>
{{ $t('form.' + kebabCase(prop) + '.checkbox.label') }}
</b-form-checkbox>
<b-form-group
:label="$t('form.' + kebabCase(prop) + '.notes.label')"
:description="$t('form.' + kebabCase(prop) + '.notes.description')"
label-class="text-primary"
class="ml-4"
>
<b-form-textarea
v-model="properties[prop].notes"
/>
</b-form-group>
</b-col>
</b-row>
<template #footer>
<c-button-submit
:disabled="disabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit')"
/>
</template>
</b-card>
</template>
<script>
import { kebabCase } from 'lodash'
export default {
i18nOptions: {
namespaces: 'system.connections',
keyPrefix: 'editor.properties',
},
props: {
properties: {
type: Object,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
},
data () {
return {
list: [
'dataAtRestEncryption',
'dataAtRestProtection',
'dataAtTransitEncryption',
'dataRestoration',
],
}
},
methods: {
kebabCase (str) {
return kebabCase(str)
},
},
}
</script>

View File

@@ -0,0 +1,173 @@
<template>
<b-card
data-test-id="card-primary-database"
class="shadow-sm"
header-class="border-bottom"
>
<template #header>
<h4 class="d-flex align-items-center gap-1 mb-0">
{{ $t('title') }}
<b-button
v-if="connection"
data-test-id="button-edit"
size="sm"
variant="outline-light"
class="d-flex align-items-center text-primary border-0 p-1"
:to="{ name: 'system.connection.edit', params: { connectionID: (connection || {}).connectionID } }"
>
<font-awesome-icon
:icon="['far', 'edit']"
/>
</b-button>
</h4>
</template>
<div
v-if="connection"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('name')"
label-class="text-primary"
class="mb-3"
>
{{ (connection.meta || {}).name }}
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('handle')"
label-class="text-primary"
class="mb-3"
>
{{ connection.handle }}
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('location')"
label-class="text-primary"
>
<c-location
:value="locationCoordinates"
:label="locationName || locationCoordinatesLabel"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('ownership')"
label-class="text-primary"
class="mb-0"
>
{{ (connection.meta || {}).ownership }}
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('sensitivity-level')"
label-class="text-primary"
>
{{ sensitivityLevelName }}
</b-form-group>
</b-col>
</b-row>
</div>
</b-card>
</template>
<script>
import CLocation from 'corteza-webapp-admin/src/components/CLocation'
import { NoID } from '@cortezaproject/corteza-js'
export default {
components: {
CLocation,
},
i18nOptions: {
namespaces: 'system.connections',
keyPrefix: 'primary',
},
data () {
return {
loading: false,
connection: undefined,
sensitivityLevel: undefined,
}
},
computed: {
locationCoordinates () {
const { coordinates = [] } = (this.connection.meta || {}).location?.geometry || {}
return coordinates || []
},
locationCoordinatesLabel () {
return (this.locationCoordinates || []).map(c => c?.toFixed?.(7) || c).join(', ')
},
locationName () {
return (this.connection.meta || {}).location?.properties?.name
},
sensitivityLevelName () {
const { sensitivityLevelID, handle, meta = {} } = this.sensitivityLevel || {}
return meta.name || handle || sensitivityLevelID || 'N/A'
},
},
created () {
this.fetchPrimaryConnection()
},
methods: {
fetchPrimaryConnection () {
this.loading = true
return this.$SystemAPI.dalConnectionList({ type: 'corteza::system:primary-dal-connection' }).then(({ set = [] }) => {
this.connection = set.find(({ type }) => type === 'corteza::system:primary-dal-connection')
if (!this.connection) {
return
}
const { sensitivityLevelID } = (this.connection.config || {}).privacy || {}
if (sensitivityLevelID && sensitivityLevelID !== NoID) {
return this.$SystemAPI.dalSensitivityLevelRead({ sensitivityLevelID })
.then(sensitivityLevel => {
this.sensitivityLevel = sensitivityLevel
})
}
}).catch(this.toastErrorHandler(this.$t('notification:connection.fetch.error')))
.finally(async () => {
this.loading = false
})
},
},
}
</script>

View File

@@ -0,0 +1,181 @@
<template>
<b-card
header-class="border-bottom"
body-class="p-0"
class="shadow-sm"
>
<template
#header
>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<c-resource-list
:primary-key="primaryKey"
:filter="filter"
:sorting="sorting"
:pagination="pagination"
:fields="fields"
:items="items"
:row-class="genericRowClass"
:translations="{
notFound: $t('admin:general.notFound'),
noItems: $t('admin:general.resource-list.no-items'),
loading: $t('loading'),
showingPagination: 'admin:general.pagination.showing',
singlePluralPagination: 'admin:general.pagination.single',
prevPagination: $t('admin:general.pagination.prev'),
nextPagination: $t('admin:general.pagination.next'),
resourceSingle: $t('general:label.connection.single'),
resourcePlural: $t('general:label.connection.plural')
}"
clickable
hide-search
class="h-100 bg-transparent"
@row-clicked="handleRowClicked"
>
<template #header>
<b-button
variant="primary"
size="lg"
:to="{ name: 'system.connection.new' }"
>
{{ $t('add-button') }}
</b-button>
</template>
<template #toolbar>
<c-resource-list-status-filter
v-model="filter.deleted"
data-test-id="filter-deleted-connections"
:label="$t('filterForm.deleted.label')"
:excluded-label="$t('filterForm.excluded.label')"
:inclusive-label="$t('filterForm.inclusive.label')"
:exclusive-label="$t('filterForm.exclusive.label')"
@change="filterList"
/>
</template>
<template #actions="{ item: c }">
<b-dropdown
v-if="c.canDeleteConnection"
variant="outline-extra-light"
toggle-class="d-flex align-items-center justify-content-center text-primary border-0 py-2"
no-caret
dropleft
lazy
menu-class="m-0"
>
<template #button-content>
<font-awesome-icon
:icon="['fas', 'ellipsis-v']"
/>
</template>
<c-input-confirm
:text="getActionText(c)"
show-icon
:icon="getActionIcon(c)"
borderless
variant="link"
size="md"
button-class="dropdown-item"
icon-class="text-danger"
class="w-100"
@confirmed="handleDelete(c)"
/>
</b-dropdown>
</template>
</c-resource-list>
</b-card>
</template>
<script>
import listHelpers from 'corteza-webapp-admin/src/mixins/listHelpers'
import moment from 'moment'
import { components } from '@cortezaproject/corteza-vue'
const { CResourceList } = components
export default {
components: {
CResourceList,
},
mixins: [
listHelpers,
],
i18nOptions: {
namespaces: 'system.connections',
keyPrefix: 'list',
},
data () {
return {
id: 'connections',
primaryKey: 'connectionID',
editRoute: 'system.connection.edit',
filter: {
type: 'corteza::system:dal-connection',
query: '',
deleted: 0,
},
sorting: {
sortBy: 'createdAt',
sortDesc: true,
},
fields: [
{
key: 'name',
sortable: false,
formatter: (value, col, conn) => conn.meta.name || conn.handle,
},
{
key: 'location',
sortable: false,
formatter: (value, col, conn) => conn.meta.location.properties.name,
},
{
key: 'ownership',
sortable: false,
formatter: (value, col, conn) => conn.meta.ownership,
},
{
key: 'createdAt',
sortable: false,
formatter: (v) => moment(v).fromNow(),
},
{
key: 'actions',
class: 'actions',
},
].map(c => ({
// Generate column label translation key
label: c.label || this.$t(`columns.${c.key}`),
...c,
})),
}
},
methods: {
items () {
return this.procListResults(this.$SystemAPI.dalConnectionListCancellable(this.encodeListParams()))
},
handleDelete (connection) {
this.handleItemDelete({
resource: connection,
resourceName: 'dalConnection',
locale: 'connection',
})
},
},
}
</script>

View File

@@ -0,0 +1,132 @@
<template>
<b-card
class="shadow-sm"
body-class="p-0"
>
<template
#header
>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<c-resource-list
primary-key="federationID"
:loading-text="$t('loading')"
:pagination="pagination"
:sorting="sorting"
:items="items"
:fields="fields"
>
<template #actions>
<b-button
variant="primary"
size="lg"
:to="{ name: 'system.connections.new' }"
>
{{ $t('add-button') }}
</b-button>
</template>
<template #filter>
<b-input-group
class="h-100"
>
<b-form-input
v-model.trim="filter.query"
:placeholder="$t('filterForm.query.placeholder')"
class="text-truncate border-right-0 h-100"
@search="filterList"
/>
<b-input-group-append>
<b-input-group-text class="text-primary bg-white">
<font-awesome-icon
:icon="['fas', 'search']"
/>
</b-input-group-text>
</b-input-group-append>
</b-input-group>
</template>
</c-resource-list>
</b-card>
</template>
<script>
import { fmt } from '@cortezaproject/corteza-js'
import listHelpers from 'corteza-webapp-admin/src/mixins/listHelpers'
export default {
mixins: [
listHelpers,
],
i18nOptions: {
namespaces: 'system.connections',
keyPrefix: 'federation',
},
data () {
return {
id: 'federation',
sorting: {
sortBy: 'createdAt',
sortDesc: true,
},
fields: [
{
key: 'name',
sortable: true,
},
{
key: 'url',
sortable: true,
tdClass: 'text-info',
},
{
key: 'location',
sortable: true,
},
{
key: 'ownership',
sortable: true,
},
{
key: 'createdBy',
sortable: true,
},
{
key: 'createdAt',
sortable: true,
formatter: v => v ? fmt.fullDateTime(v) : v,
},
{
key: 'actions',
class: 'text-right',
},
].map(c => ({
...c,
// Generate column label translation key
label: c.label || this.$t(`columns.${c.key}`),
})),
}
},
methods: {
items () {
const set = [
{ federationID: '1', name: 'ACME France', url: 'https://corteza.acme.fr', location: 'France', ownership: 'ACME SARL', createdBy: 'John Doe', createdAt: new Date() },
]
const filter = {
count: set.length,
limit: 10,
}
return this.procListResults(new Promise(resolve => setTimeout(resolve({ filter, set })), 200), false)
},
},
}
</script>

View File

@@ -0,0 +1,49 @@
import { default as component } from './CFederationEditorInfo.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
node: {
nodeID: '',
name: 'Name',
baseURL: 'https://example.com/federation',
contact: 'email@example.com',
},
processing: false,
success: false,
}
export default {
name: 'Editor info',
group: ['Federation'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
checkbox('Enable delete', {
value (p) { return p.node.nodeID.length > 0 },
update (p, val) { p.node.nodeID = val ? '123456789' : '' },
}),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
node: {
...props.node,
name: '',
baseURL: '',
contact: '',
},
},
},
],
}

View File

@@ -0,0 +1,176 @@
<template>
<b-card
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
@submit.prevent="$emit('submit', node)"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('name')"
label-class="text-primary"
>
<b-form-input
v-model="node.name"
:state="nameState"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('url')"
label-class="text-primary"
>
<b-form-input
v-model="node.baseURL"
placeholder="https://example.com/federation"
type="url"
:state="urlState"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('email')"
label-class="text-primary"
>
<b-form-input
v-model="node.contact"
placeholder="email@example.com"
type="email"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
v-if="node.status"
:label="$t('status')"
label-class="text-primary"
>
{{ node.status }}
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:resource="node"
/>
<!--
include hidden input to enable
trigger submit event w/ ENTER
-->
<input
type="submit"
class="d-none"
:disabled="saveDisabled"
>
</b-form>
<template #footer>
<c-input-confirm
v-if="node && node.nodeID"
variant="danger"
size="md"
:text="getDeleteStatus"
@confirmed="$emit('delete')"
/>
<c-button-submit
:disabled="saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', node)"
/>
</template>
</b-card>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
export default {
name: 'CFederationEditorInfo',
i18nOptions: {
namespaces: 'federation.nodes',
keyPrefix: 'editor.info',
},
props: {
node: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
value: false,
},
},
computed: {
fresh () {
return !this.node.nodeID || this.node.nodeID === NoID
},
editable () {
return this.fresh ? this.canCreate : this.node.canManageNode
},
saveDisabled () {
return !this.editable || [this.nameState, this.urlState].includes(false)
},
nameState () {
const { name } = this.node
return name ? null : false
},
urlState () {
const { baseURL = '' } = this.node
return /https?:\/\/*.*\/federation/gm.test(baseURL) ? null : false
},
getDeleteStatus () {
return this.node.deletedAt ? this.$t('undelete') : this.$t('delete')
},
},
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div
class="d-inline-block"
>
<b-button
data-test-id="button-clone"
variant="light"
class="mr-2"
@click="showModal = true"
>
{{ $t('ui.clone.label') }}
</b-button>
<b-modal
v-model="showModal"
data-test-id="modal-clone-permission"
ok-variant="primary"
cancel-variant="light"
centered
:title="$t('ui.clone.title')"
:ok-title="$t('ui.clone.clone')"
:ok-disabled="!selectedRoles.length || processingSubmit"
@ok="clonePermissions"
>
<b-form-group
:description="$t('ui.clone.description')"
class="mb-0"
>
<c-input-role
v-model="selectedRoles"
data-test-id="select-role-list"
:selectable="r => !selectedRoles.some(rr => rr.roleID === r.roleID)"
:placeholder="$t('ui.clone.pick-role')"
multiple
/>
</b-form-group>
</b-modal>
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CInputRole } = components
export default {
i18nOptions: {
namespaces: 'permissions',
},
components: {
CInputRole,
},
props: {
roleId: {
type: String,
required: false,
default: undefined,
},
},
data () {
return {
showModal: false,
selectedRoles: [],
processingSubmit: false,
}
},
methods: {
clonePermissions () {
this.processingSubmit = true
const cloneToRoleID = this.selectedRoles.map(({ roleID }) => roleID)
this.$SystemAPI.roleCloneRules({ roleID: this.roleId, cloneToRoleID })
.then(() => {
this.selectedRoles = []
this.toastSuccess(this.$t('notification:permissions.clone.success'))
})
.catch(this.toastErrorHandler(this.$t('notification:permissions.clone.error')))
.finally(() => {
this.processingSubmit = false
})
},
},
}
</script>

View File

@@ -0,0 +1,526 @@
<template>
<div class="h-100">
<b-card
header-class="p-0"
body-class="overflow-auto p-0"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow h-100"
>
<template
v-if="loaded && canGrant"
#header
>
<b-row
no-gutters
align-v="stretch"
class="border-bottom"
>
<b-col
cols="4"
class="text-left p-3"
>
<small>
{{ $t('ui.click-on-cell-to-allow') }}
</small>
</b-col>
<b-col
v-for="role in roles"
:key="role.ID"
data-test-id="button-hide-role"
class="d-flex flex-column align-items-center justify-content-center pointer hide-role border-left p-3 overflow-hidden"
@click="onHideRole(role)"
>
<label
v-for="(n, index) in role.name"
:key="index"
:title="n"
class="pointer text-center text-primary text-break mb-1"
>
{{ n }}
</label>
<font-awesome-icon
:icon="['fas', 'plus']"
class="text-light rotate"
/>
</b-col>
<b-col
v-if="roles.length < 8"
v-b-modal.add
data-test-id="button-add-role"
class="d-flex flex-column align-items-center justify-content-center border-left p-3 overflow-hidden"
>
<label
class="pointer text-center text-primary text-break mb-1"
>
{{ $t('ui.add.label') }}
</label>
<font-awesome-icon
:icon="['fas', 'plus']"
class="text-success"
/>
</b-col>
</b-row>
</template>
<div
v-if="!loaded || !canGrant"
class="d-flex align-items-center justify-content-center h-100 pb-4"
>
<div
v-if="!loaded"
>
<b-spinner
class="align-middle m-5"
/>
<div>
{{ $t('ui.loading') }}
</div>
</div>
<div
v-else-if="!canGrant"
class="text-danger"
>
{{ $t('ui.not-allowed') }}
</div>
</div>
<template v-else>
<div
v-for="(type, i) in sortedPermissions"
:key="type"
>
<b-row
class="bg-light border-bottom text-primary sticky-top"
align-v="stretch"
no-gutters
>
<b-col
cols="4"
align-self="center"
class="p-3 text-left"
>
<span class="h-100 h6 mb-0">
{{ getTranslation(type) }}
</span>
</b-col>
<b-col
v-for="role in roles"
:key="role.ID"
class="d-flex align-items-center justify-content-center overflow-hidden p-3 text-center border-left not-allowed"
>
<p
v-if="i === 0"
:title="$t(`ui.${role.mode === 'edit' ? 'edit' : 'evaluate'}.title`)"
class="mb-0"
>
{{ $t(`ui.${role.mode === 'edit' ? 'edit' : 'evaluate'}.title`) }}
</p>
</b-col>
<b-col
class="p-3 border-left not-allowed"
/>
</b-row>
<b-row
v-for="operation in permissions[type].ops"
:key="operation"
:data-test-id="`permission-${operation}`"
no-gutters
>
<b-col
class="border-bottom text-left p-3"
cols="4"
>
<span
:title="getTranslation(type, operation)"
>
{{ getTranslation(type, operation) }}
</span>
</b-col>
<b-col
v-for="role in roles"
:key="role.ID"
v-b-tooltip.noninteractive.hover="{ title: getRuleTooltip(checkRule(role.ID, permissions[type].any, operation, 'unknown-context'), !!role.userID), boundary: 'body' }"
class="d-flex align-items-center justify-content-center border-bottom border-left p-3 pointer active-cell h5 mb-0"
:class="{
'not-allowed bg-extra-light': role.mode === 'eval',
'bg-warning': checkChange(role.ID, permissions[type].any, operation)
}"
@click="role.mode === 'edit' ? ruleChange($event, role.ID, permissions[type].any, operation) : undefined"
>
<font-awesome-icon
v-if="checkRule(role.ID, permissions[type].any, operation, 'unknown-context')"
data-test-id="permission-unknown"
:icon="['fas', 'question']"
class="text-secondary"
/>
<font-awesome-icon
v-else-if="checkRule(role.ID, permissions[type].any, operation, 'allow')"
data-test-id="permission-allowed"
:icon="['fas', 'check']"
class="text-success"
/>
<font-awesome-icon
v-else
data-test-id="permission-denied"
:icon="['fas', 'times']"
class="text-danger"
/>
</b-col>
<b-col
v-if="roles.length < 8"
class="border-bottom border-left p-3 not-allowed bg-extra-light"
/>
</b-row>
</div>
</template>
<template
v-if="loaded && canGrant"
#footer
>
<c-button-submit
:processing="processing"
:success="success"
:text="$t('ui.save')"
class="ml-auto"
@submit="onSubmit"
/>
</template>
</b-card>
<b-modal
id="add"
:title="$t('ui.edit-or-eval')"
centered
ok-only
:ok-title="$t('ui.add.save')"
:ok-disabled="!addEnabled"
@ok="onAdd"
>
<b-form-group>
<b-form-radio-group
v-model="add.mode"
data-test-id="button-mode"
:options="modeOptions"
buttons
button-variant="outline-primary"
class="mode rounded w-100"
/>
</b-form-group>
<p>
{{ addModeDescription }}
</p>
<b-form-group
:label="$t('ui.add.role.label')"
label-class="text-primary"
class="mb-0"
>
<c-input-role
v-model="add.roleID"
:data-test-id="`select-${add.mode}-roles`"
:placeholder="$t('ui.add.role.placeholder')"
:visible="isRoleVisible"
:multiple="add.mode === 'eval'"
:disabled="add.mode === 'eval' && !!add.userID"
/>
</b-form-group>
<b-form-group
v-if="add.mode === 'eval'"
:label="$t('ui.add.user.label')"
label-class="text-primary"
class="mt-3 mb-0"
>
<c-input-select
v-model="add.userID"
:data-test-id="`select-${add.mode}-users`"
:disabled="!!add.roleID.length"
:options="userOptions"
:get-option-label="getUserLabel"
:placeholder="$t('ui.add.user.placeholder')"
:filterable="false"
@search="searchUsers"
/>
</b-form-group>
</b-modal>
</div>
</template>
<script>
import _ from 'lodash'
import { components } from '@cortezaproject/corteza-vue'
const { CInputRole } = components
export default {
i18nOptions: {
namespaces: 'permissions',
},
components: {
CInputRole,
},
props: {
roles: {
type: Array,
required: true,
},
permissions: {
type: Object,
required: true,
},
rolePermissions: {
type: Array,
required: true,
},
canGrant: {
type: Boolean,
value: false,
},
loaded: {
type: Boolean,
value: false,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
component: {
type: String,
required: true,
},
},
data () {
return {
add: {
// Either edit or eval
mode: 'edit',
roleID: [],
userID: undefined,
},
modeOptions: [
{ text: 'Edit', value: 'edit' },
{ text: 'Evaluate', value: 'eval' },
],
userOptions: [],
evaluatedPermissions: undefined,
newRole: null,
permissionChanges: [],
fetchedUsers: {},
}
},
computed: {
sortedPermissions () {
return Object.keys(this.permissions).sort()
},
addModeDescription () {
return this.add.mode === 'edit' ? this.$t('ui.add.edit.description') : this.$t('ui.add.evaluate.description')
},
addEnabled () {
const { mode, roleID = [], userID } = this.add
if (mode === 'edit') {
return Array.isArray(roleID) ? roleID.length : roleID
} else if (mode === 'eval') {
return (roleID && roleID.length) || userID
}
return false
},
},
watch: {
'add.mode': {
handler (mode) {
this.add.roleID = mode === 'eval' ? [] : undefined
this.add.userID = undefined
},
},
},
mounted () {
this.searchUsers('', () => {})
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
checkRule (ID, res, op, access) {
const key = `${op}@${res}`
return (this.rolePermissions.find(r => r.ID === ID) || { rules: {} }).rules[key] === access
},
checkChange (ID, res, op) {
const key = `${op}@${res}`
const current = (this.rolePermissions.find(r => r.ID === ID) || { rules: {} }).rules[key]
const initial = (this.permissionChanges.find(r => r.ID === ID) || { rules: {} }).rules[key]
if (initial) {
return current !== initial
} else {
return false
}
},
ruleChange (event, ID, res, op) {
const key = `${op}@${res}`
let access = (this.rolePermissions.find(r => r.ID === ID) || { rules: {} }).rules[key]
// Keep track of permission changes, record initial value before it changes
if (!(this.permissionChanges.find(r => r.ID === ID) || { rules: {} }).rules[key]) {
this.permissionChanges.push({ ID, rules: { } })
if (!access) {
access = 'inherit'
}
this.$set(this.permissionChanges.find(r => r.ID === ID).rules, key, access)
}
if (access === 'allow') {
access = 'inherit'
} else {
access = 'allow'
}
this.$set(this.rolePermissions.find(r => r.ID === ID).rules, key, access)
},
searchUsers (query = '', loading) {
loading(true)
this.$SystemAPI.userList({ query, limit: 15 })
.then(({ set }) => {
this.userOptions = set.reduce((acc, { userID, name, username, email }) => {
if (!this.fetchedUsers[userID]) {
this.fetchedUsers[userID] = name || username || email || `<@${userID}>`
}
acc.push(userID)
return acc
}, [])
})
.finally(() => {
loading(false)
})
},
isRoleVisible ({ isBypass }) {
return this.add.mode === 'edit' || !isBypass
},
getUserLabel (userID) {
return this.fetchedUsers[userID]
},
getTranslation (resource, operation = '') {
resource = _.kebabCase(resource.split(':')[3]) || 'component'
if (operation) {
return this.$t(`resources.${this.component}.${resource}.operations.${operation}.title`)
} else {
return this.$t(`resources.${this.component}.${resource}.label`)
}
},
getRuleTooltip (isUnknown = false, isUser) {
if (!isUnknown) {
return ''
}
return this.$t(`ui.tooltip.unknown-context.${isUser ? 'user' : 'role'}`)
},
onSubmit () {
this.$emit('submit', this.rolePermissions)
this.permissionChanges = []
},
onAdd () {
let { userID } = this.add
if (userID) {
userID = { userID: this.add.userID, name: this.fetchedUsers[this.add.userID] }
}
this.$emit('add', { ...this.add, userID })
this.add = {
mode: 'edit',
roleID: [],
userID: undefined,
}
},
onHideRole (role) {
this.$emit('hide', role)
},
setDefaultValues () {
this.add = {}
this.modeOptions = []
this.userOptions = []
this.evaluatedPermissions = undefined
this.newRole = null
this.permissionChanges = []
this.fetchedUsers = {}
},
},
}
</script>
<style lang="scss" scoped>
.pointer {
cursor: pointer;
}
.not-allowed {
cursor: not-allowed;
}
.active-cell:hover {
background-color: var(--gray-200);
}
.rotate {
transform: rotate(45deg);
}
.hide-role:hover {
.rotate {
color: var(--dark) !important;
}
}
</style>
<style lang="scss">
.mode {
.btn {
background-color: var(--light);
border: none;
}
.btn:nth-child(2), .btn:nth-child(3) {
margin-left: 0.2rem !important;
}
}
</style>

View File

@@ -0,0 +1,39 @@
import { default as component } from './CQueueEditorInfo.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
queue: {
queueID: '',
queue: 'Name',
meta: {
poll_delay: '1h',
dispatch_events: 'test',
},
consumer: null,
},
processing: false,
success: false,
canCreate: true,
consumers: [
{ value: 'dummy', text: 'Dummy' },
],
}
export default {
name: 'Editor info',
group: ['Queues'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
checkbox('CanCreate', 'canCreate'),
checkbox('Enable delete', {
value (p) { return p.queue.queueID.length > 0 },
update (p, val) { p.queue.queueID = val ? '123456789' : '' },
}),
],
}

View File

@@ -0,0 +1,216 @@
<template>
<b-card
data-test-id="card-queue-edit"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t("title") }}
</h4>
</template>
<b-form @submit="$emit('submit', queue)">
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('name')"
label-class="text-primary"
>
<b-form-input
v-model="queue.queue"
data-test-id="input-name"
:state="handleState"
/>
<b-form-invalid-feedback
:state="handleState"
data-test-id="feedback-invalid-name"
>
{{ $t('invalid-handle-characters') }}
</b-form-invalid-feedback>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('consumer')"
label-class="text-primary"
>
<b-form-select
v-model="queue.consumer"
data-test-id="input-consumer"
:options="consumers"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('poll_delay')"
:description="metaPollDelayDescription()"
label-class="text-primary"
>
<b-form-input
v-model="(queue.meta || {}).poll_delay"
data-test-id="input-polling"
:state="durationState"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
v-if="isMetaDispatchEvents"
:label="$t('dispatch_events')"
:description="$t('dispatch_events_desc')"
label-class="text-primary"
>
<b-form-checkbox
v-model="queue.meta.dispatch_events"
name="checkbox-1"
>
{{ $t("dispatch_events") }}
</b-form-checkbox>
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:resource="queue"
/>
</b-form>
<template #footer>
<c-input-confirm
v-if="queue && queue.queueID && queue.canDeleteQueue"
:data-test-id="deleteButtonStatusCypressId"
:text="getDeleteStatus"
variant="danger"
size="md"
@confirmed="$emit('delete')"
/>
<c-button-submit
:disabled="saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', queue)"
/>
</template>
</b-card>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
import { handle } from '@cortezaproject/corteza-vue'
export default {
name: 'CQueueEditorInfo',
i18nOptions: {
namespaces: 'system.queues',
keyPrefix: 'editor.info',
},
props: {
consumers: {
type: Array,
required: true,
},
queue: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
computed: {
fresh () {
return !this.queue.queueID || this.queue.queueID === NoID
},
editable () {
return this.fresh ? this.canCreate : true // this.queue.canUpdateQueue
},
saveDisabled () {
return !this.editable || [this.durationState, this.handleState].includes(false)
},
durationState () {
const pd = (this.queue.meta || {}).poll_delay || ''
const m = pd.match(/^((\d+h)?(\d+m)?(\d+s)?)|(\s)$/g)
if (m.length && m[0] === pd) {
return null
}
return false
},
handleState () {
const { queue = '' } = this.queue
return queue ? handle.handleState(queue) : false
},
isMetaPollDelay () {
if (this.queue.queueID) {
return ((this.queue.meta || {}).poll_delay || '') === ''
}
return true
},
isMetaDispatchEvents () {
return ((this.queue || {}).meta || {}).dispatch_events === null
},
getDeleteStatus () {
return this.queue.deletedAt ? this.$t('undelete') : this.$t('delete')
},
deleteButtonStatusCypressId () {
return `button-${this.getDeleteStatus.toLowerCase()}`
},
},
methods: {
metaPollDelayDescription () {
return (((this.queue || {}).meta || {}).poll_delay || null)
? this.$t('poll_delay_set')
: this.$t('poll_delay_empty')
},
},
}
</script>

View File

@@ -0,0 +1 @@
export { default as CRoleEditorInfo } from './CRoleEditorInfo.c3'

View File

@@ -0,0 +1,50 @@
import { default as component } from './CRoleEditorInfo.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
role: {
roleID: '',
name: 'Federation',
handle: 'federation',
},
processing: false,
success: false,
canCreate: true,
}
export default {
name: 'Editor info',
group: ['Role'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
checkbox('CanCreate', 'canCreate'),
checkbox('Enable delete and archive', {
value (p) { return p.role.roleID.length > 0 },
update (p, val) { p.role.roleID = val ? '123456789' : '' },
}),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
role: {
...props.role,
name: '',
handle: '',
},
canCreate: false,
},
},
],
}

View File

@@ -0,0 +1,325 @@
<template>
<b-card
data-test-id="card-role-info"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<b-form
@submit.prevent="submit()"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('name')"
label-class="text-primary"
>
<b-form-input
v-model="role.name"
data-test-id="input-name"
:state="nameState"
:disabled="!editable"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('handle')"
label-class="text-primary"
>
<b-form-input
v-model="role.handle"
data-test-id="input-handle"
:state="handleState"
:disabled="!editable"
:placeholder="$t('placeholder-handle')"
/>
<b-form-invalid-feedback :state="handleState">
{{ $t('invalid-handle-characters') }}
</b-form-invalid-feedback>
</b-form-group>
</b-col>
<b-col cols="12">
<b-form-group
v-if="role.meta"
:label="$t('description')"
label-class="text-primary"
>
<b-form-textarea
v-model="role.meta.description"
data-test-id="textarea-description"
:disabled="!editable"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('context.label')"
label-class="text-primary"
>
<c-input-checkbox
v-model="isContextual"
data-test-id="checkbox-is-contextual"
switch
:labels="checkboxLabel"
:disabled="!editable"
/>
</b-form-group>
</b-col>
</b-row>
<b-row
v-if="isContextual"
class="my-3"
>
<b-col
cols="12"
>
<b-form-group
:label="$t('context.expression-label')"
label-class="text-primary"
>
<b-form-input
v-model="role.meta.context.expr"
data-test-id="input-expression"
:disabled="!editable"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
>
<b-form-group
:label="$t('context.resource-types-label')"
label-class="text-primary"
>
<b-checkbox
v-for="(resourceType, i) in resourceTypeOptions"
:key="i"
v-model="role.meta.context.resourceTypes"
:data-test-id="`checkbox-resource-type-${resourceType.text}`"
:value="resourceType.value"
:disabled="!editable"
>
{{ resourceType.text }}
</b-checkbox>
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:resource="role"
/>
<!--
include hidden input to enable
trigger submit event w/ ENTER
-->
<input
type="submit"
class="d-none"
:disabled="saveDisabled"
>
</b-form>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<template #footer>
<c-input-confirm
v-if="!fresh && editable && role.canDeleteRole && !isDataPrivacyOfficer"
:data-test-id="deletedButtonStatusCypressId"
:text="getDeleteStatus"
variant="danger"
size="md"
@confirmed="$emit('delete')"
/>
<c-input-confirm
v-if="!fresh && editable && !isDataPrivacyOfficer"
:data-test-id="archivedButtonStatusCypressId"
:text="getArchiveStatus"
variant="secondary"
size="md"
@confirmed="$emit('status')"
/>
<c-button-submit
:disabled="saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="submit()"
/>
</template>
</b-card>
</template>
<script>
import { system, NoID } from '@cortezaproject/corteza-js'
import { handle } from '@cortezaproject/corteza-vue'
export default {
name: 'CRoleEditorInfo',
i18nOptions: {
namespaces: 'system.roles',
keyPrefix: 'editor.info',
},
props: {
role: {
type: system.Role,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
isContext: {
type: Boolean,
required: true,
},
canCreate: {
type: Boolean,
required: true,
},
},
data () {
return {
checkboxLabel: {
on: this.$t('general:label.general.yes'),
off: this.$t('general:label.general.no'),
},
}
},
computed: {
isContextual: {
get () {
return this.isContext
},
set (isContext) {
this.role.meta.context.resourceTypes = []
this.role.meta.context.expr = ''
this.$emit('update:is-context', isContext)
},
},
saveDisabled () {
return !this.editable || [this.nameState, this.handleState].includes(false)
},
/**
* Returns true if role is not saved yet (without ID)
* @returns {boolean}
*/
fresh () {
return !this.role.roleID || this.role.roleID === NoID
},
editable () {
return this.fresh ? this.canCreate : !this.role.isSystem && this.role.canUpdateRole
},
// At least 1 character
nameState () {
const { name } = this.role
return name ? null : false
},
// 2+ alpha-numeric + _
handleState () {
return handle.handleState(this.role.handle)
},
getDeleteStatus () {
return this.role.deletedAt ? this.$t('undelete') : this.$t('delete')
},
getArchiveStatus () {
return this.role.archivedAt ? this.$t('unarchive') : this.$t('archive')
},
resourceTypeOptions () {
return this.resourceTypes.map(value => ({
// @todo use translation facility to generate resource type option labels
text: value.replace('corteza::', ''),
value,
}))
},
resourceTypes () {
// @todo this should be fetched from the backend
return [
// 'corteza::system:application',
'corteza::system:auth-client',
'corteza::system:role',
// 'corteza::system:template',
'corteza::system:user',
// 'corteza::compose:chart',
// 'corteza::compose:module-field',
'corteza::compose:module',
'corteza::compose:namespace',
'corteza::compose:page',
'corteza::compose:record',
'corteza::automation:workflow',
// 'corteza::federation:exposed-module',
// 'corteza::federation:node',
// 'corteza::federation:shared-module',
]
},
deletedButtonStatusCypressId () {
return `button-${this.getDeleteStatus.toLowerCase()}`
},
archivedButtonStatusCypressId () {
return `button-${this.getArchiveStatus.toLowerCase()}`
},
isDataPrivacyOfficer () {
return this.role.handle === 'data-privacy-officer'
},
},
methods: {
submit () {
if (!this.isContextual && this.role.isContext) {
// if checkbox was unchecked on submit, purge meta before submit
this.role.meta.context.resourceTypes = []
this.role.meta.context.expr = ''
}
this.$emit('submit', this.role)
},
},
}
</script>

View File

@@ -0,0 +1,64 @@
<template>
<b-card
data-test-id="card-role-edit-members"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form @submit.prevent="$emit('submit')">
<c-member-picker
:value="value"
@input="$emit('input', $event)"
/>
</b-form>
<template #footer>
<c-button-submit
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit')"
/>
</template>
</b-card>
</template>
<script>
import CMemberPicker from 'corteza-webapp-admin/src/components/CMemberPicker'
export default {
i18nOptions: {
namespaces: 'system.roles',
keyPrefix: 'editor.members',
},
components: {
CMemberPicker,
},
props: {
value: {
type: Array,
required: true,
default: () => [],
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
},
}
</script>

View File

@@ -0,0 +1,180 @@
<template>
<b-card
data-test-id="card-sens-lvl-info"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
@submit.prevent="$emit('submit', sensitivityLevel)"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('name')"
label-class="text-primary"
>
<b-form-input
v-model="sensitivityLevel.meta.name"
data-test-id="input-name"
required
:state="nameState"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('handle.label')"
label-class="text-primary"
>
<b-form-input
v-model="sensitivityLevel.handle"
:placeholder="$t('handle.placeholder')"
:state="handleState"
/>
<b-form-invalid-feedback :state="handleState">
{{ $t('handle.invalid-characters') }}
</b-form-invalid-feedback>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('level', sensitivityLevel)"
>
<b-form-input
v-model="sensitivityLevel.level"
number
type="range"
min="1"
max="10"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('description')"
>
<b-form-textarea
v-model="sensitivityLevel.meta.description"
/>
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:resource="sensitivityLevel"
/>
<!--
include hidden input to enable
trigger submit event w/ ENTER
-->
<input
type="submit"
class="d-none"
:disabled="saveDisabled"
>
</b-form>
<template #footer>
<c-input-confirm
v-if="sensitivityLevel && sensitivityLevel.sensitivityLevelID"
:text="getDeleteStatus"
variant="danger"
size="md"
@confirmed="$emit('delete')"
/>
<c-button-submit
:disabled="saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', sensitivityLevel)"
/>
</template>
</b-card>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
import { handle } from '@cortezaproject/corteza-vue'
export default {
name: 'CSensitivityLevelEditorInfo',
i18nOptions: {
namespaces: 'system.sensitivityLevel',
keyPrefix: 'editor.info',
},
props: {
sensitivityLevel: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
computed: {
fresh () {
return !this.sensitivityLevel.sensitivityLevelID || this.sensitivityLevel.sensitivityLevelID === NoID
},
editable () {
return this.fresh ? this.canCreate : true
},
nameState () {
return this.sensitivityLevel.meta.name ? null : false
},
handleState () {
return handle.handleState(this.sensitivityLevel.handle)
},
saveDisabled () {
return !this.editable || [this.nameState, this.handleState].includes(false)
},
getDeleteStatus () {
return this.sensitivityLevel.deletedAt ? this.$t('undelete') : this.$t('delete')
},
},
}
</script>

View File

@@ -0,0 +1,176 @@
<template>
<b-card
class="shadow-sm"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap gap-1"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
v-if="session && session.sessionID"
>
<b-row>
<b-col
v-if="session.workflowID"
cols="12"
lg="6"
>
<b-form-group
:label="$t('workflowID')"
label-class="text-primary"
>
{{ session.workflowID }}
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('status')"
label-class="text-primary"
class="mb-0"
>
{{ session.status }}
</b-form-group>
</b-col>
<b-col
v-if="session.error"
cols="12"
lg="6"
>
<b-form-group
:label="$t('error')"
label-class="text-primary"
>
{{ session.error }}
</b-form-group>
</b-col>
<b-col
v-if="session.resourceType"
cols="12"
lg="6"
>
<b-form-group
:label="$t('resourceType')"
label-class="text-primary"
>
{{ session.resourceType }}
</b-form-group>
</b-col>
<b-col
v-if="session.eventType"
cols="12"
lg="6"
>
<b-form-group
:label="$t('eventType')"
label-class="text-primary"
>
{{ session.eventType }}
</b-form-group>
</b-col>
<b-col
v-if="!session.completedAt"
cols="12"
>
<b-form-group
label-class="text-primary"
>
<b-button
variant="danger"
:disabled="processing"
@click="$emit('cancel')"
>
{{ $t('cancel') }}
</b-button>
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:id="session.sessionID"
:resource="session"
>
<template #custom-field>
<b-col
v-if="createdByUserName"
cols="12"
lg="4"
>
<b-form-group
:label="$t('createdByUserName')"
label-class="text-primary"
>
<c-user-label :user="user" />
</b-form-group>
</b-col>
</template>
</c-system-fields>
</b-form>
<template
v-if="session.workflowID"
#footer
>
<b-button
variant="light"
:to="{ name: 'automation.workflow.edit', params: { workflowID: session.workflowID } }"
>
{{ $t('openWorkflow') }}
</b-button>
</template>
</b-card>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CUserLabel } = components
export default {
name: 'CSessionEditorInfo',
components: {
CUserLabel,
},
i18nOptions: {
namespaces: 'automation.sessions',
keyPrefix: 'editor.info',
},
props: {
session: {
type: Object,
required: true,
},
user: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
},
computed: {
createdByUserName () {
const { userID, name, username, email } = this.user
return name || username || email || `<@${userID}>`
},
},
}
</script>

View File

@@ -0,0 +1,3 @@
export { default as CComposeEditorBasic } from './Compose/CComposeEditorBasic.c3'
export { default as CSystemEditorAuth } from './System/CSystemEditorAuth.c3'
export { default as CSystemEmailServer } from './Email/CSystemEmailServer.c3'

View File

@@ -0,0 +1,45 @@
import { default as component } from './CComposeEditorBasic.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
basic: {
'compose.page.attachments.max-size': 22,
'compose.record.attachments.max-size': 22,
},
processing: false,
success: false,
canManage: true,
}
export default {
name: 'Compose editor basic',
group: ['Settings'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
checkbox('CanManage', 'canManage'),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
basic: {
...props.basic,
'compose.page.attachments.max-size': '',
'compose.record.attachments.max-size': '',
},
canCreate: false,
},
},
],
}

View File

@@ -0,0 +1,259 @@
<template>
<b-card
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
@submit.prevent="$emit('submit', basic)"
>
<div class="pb-3">
<h5>{{ $t('attachments.namespace') }}</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('attachments.max-size')"
label-class="text-primary"
>
<b-form-input
v-model="basic['compose.namespace.attachments.max-size']"
type="number"
number
placeholder="0"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('attachments.type.whitelist')"
:description="$t('attachments.type.description')"
label-class="text-primary"
class="mb-0"
>
<b-form-input v-model="namespaceAttachmentWhitelist" />
</b-form-group>
</b-col>
</b-row>
</div>
<div class="pb-3">
<h5>{{ $t('attachments.page') }}</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('attachments.max-size')"
label-class="text-primary"
>
<b-form-input
v-model="basic['compose.page.attachments.max-size']"
type="number"
number
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('attachments.type.whitelist')"
:description="$t('attachments.type.description')"
label-class="text-primary"
class="mb-0"
>
<b-form-input v-model="pageAttachmentWhitelist" />
</b-form-group>
</b-col>
</b-row>
</div>
<div class="pb-3">
<h5>{{ $t('attachments.record') }}</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('attachments.max-size')"
label-class="text-primary"
>
<b-form-input
v-model="basic['compose.record.attachments.max-size']"
type="number"
number
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('attachments.type.whitelist')"
:description="$t('attachments.type.description')"
label-class="text-primary"
class="mb-0"
>
<b-form-input v-model="recordAttachmentWhitelist" />
</b-form-group>
</b-col>
</b-row>
</div>
<div>
<h5>{{ $t('attachments.icon') }}</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('attachments.max-size')"
label-class="text-primary"
>
<b-form-input
v-model="basic['compose.icon.attachments.max-size']"
type="number"
number
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('attachments.type.whitelist')"
:description="$t('attachments.type.description')"
label-class="text-primary"
class="mb-0"
>
<b-form-input v-model="iconAttachmentWhitelist" />
</b-form-group>
</b-col>
</b-row>
</div>
</b-form>
<template #footer>
<c-button-submit
v-if="canManage"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', basic)"
/>
</template>
</b-card>
</template>
<script>
export default {
name: 'CComposeEditorBasic',
i18nOptions: {
namespaces: 'compose.settings',
keyPrefix: 'editor.basic',
},
props: {
basic: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canManage: {
type: Boolean,
required: true,
},
},
computed: {
pageAttachmentWhitelist: {
get () {
return (this.basic['compose.page.attachments.mimetypes'] || []).join(',')
},
set (value) {
this.basic['compose.page.attachments.mimetypes'] = this.convertToExternal(value)
},
},
recordAttachmentWhitelist: {
get () {
return (this.basic['compose.record.attachments.mimetypes'] || []).join(',')
},
set (value) {
this.basic['compose.record.attachments.mimetypes'] = this.convertToExternal(value)
},
},
iconAttachmentWhitelist: {
get () {
return (this.basic['compose.icon.attachments.mimetypes'] || []).join(',')
},
set (value) {
this.basic['compose.icon.attachments.mimetypes'] = this.convertToExternal(value)
},
},
namespaceAttachmentWhitelist: {
get () {
return (this.basic['compose.namespace.attachments.mimetypes'] || []).join(',')
},
set (value) {
this.basic['compose.namespace.attachments.mimetypes'] = this.convertToExternal(value)
},
},
},
methods: {
convertToExternal (value) {
return (value || '').split(',').map(v => {
return v.replace(/ /g, '')
}).filter(v => {
return v.match(/^[-\w.]+\/[-\w/+.]+$/g) !== null
})
},
},
}
</script>

View File

@@ -0,0 +1,141 @@
<template>
<b-card
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
@submit.prevent="$emit('submit', settings)"
>
<b-form-group
:label="$t('sidebar.title')"
label-class="text-primary"
>
<b-form-checkbox
v-model="sidebar.hideNamespaceList"
>
{{ $t('sidebar.hide-namespace-list') }}
</b-form-checkbox>
<b-form-checkbox
v-model="sidebar.hideNamespaceListLink"
>
{{ $t('sidebar.hide-namespace-list-link') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
:label="$t('record-toolbar.title')"
label-class="text-primary"
class="mb-0"
>
<b-form-checkbox
v-model="recordToolbar.hideSubmit"
>
{{ $t('record-toolbar.hide-submit') }}
</b-form-checkbox>
<b-form-checkbox
v-model="recordToolbar.hideDelete"
>
{{ $t('record-toolbar.hide-delete') }}
</b-form-checkbox>
<b-form-checkbox
v-model="recordToolbar.hideEdit"
>
{{ $t('record-toolbar.hide-edit') }}
</b-form-checkbox>
<b-form-checkbox
v-model="recordToolbar.hideNew"
>
{{ $t('record-toolbar.hide-new') }}
</b-form-checkbox>
<b-form-checkbox
v-model="recordToolbar.hideClone"
>
{{ $t('record-toolbar.hide-clone') }}
</b-form-checkbox>
<b-form-checkbox
v-model="recordToolbar.hideBack"
>
{{ $t('record-toolbar.hide-back') }}
</b-form-checkbox>
</b-form-group>
</b-form>
<template #footer>
<c-button-submit
v-if="canManage"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="onSubmit"
/>
</template>
</b-card>
</template>
<script>
export default {
name: 'CComposeEditorUI',
i18nOptions: {
namespaces: 'compose.settings',
keyPrefix: 'editor.ui',
},
props: {
settings: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canManage: {
type: Boolean,
required: true,
},
},
data () {
return {
sidebar: {},
recordToolbar: {},
}
},
watch: {
settings: {
immediate: true,
handler (settings) {
this.sidebar = settings['compose.ui.sidebar'] || {}
this.recordToolbar = settings['compose.ui.record-toolbar'] || {}
},
},
},
methods: {
onSubmit () {
this.$emit('submit', {
'compose.ui.sidebar': this.sidebar,
'compose.ui.record-toolbar': this.recordToolbar,
})
},
},
}
</script>

View File

@@ -0,0 +1,23 @@
import { default as component } from './CSystemEmailServer.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
value: {},
processing: false,
success: false,
disabled: false,
}
export default {
name: 'Email server',
group: ['Settings'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
checkbox('Disabled', 'disabled'),
],
}

View File

@@ -0,0 +1,248 @@
<template>
<b-card
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
@submit.prevent="submit()"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('host.label')"
:description="$t('host.description')"
label-class="text-primary"
>
<b-input-group>
<b-input
v-model="server.host"
data-test-id="input-server"
:disabled="disabled"
placeholder="host.domain.tld"
autocomplete="off"
required
/>
<b-input-group-append is-text>
:
</b-input-group-append>
<b-input-group-append>
<b-input
v-model="server.port"
data-test-id="input-server-port"
type="number"
:disabled="disabled"
step="1"
required
/>
</b-input-group-append>
</b-input-group>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('user.label')"
:description="$t('user.description')"
label-class="text-primary"
>
<b-input
v-model="server.user"
data-test-id="input-user"
:disabled="disabled"
autocomplete="off"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('password.label')"
:description="$t('password.description')"
label-class="text-primary"
>
<b-input
v-model="server.pass"
data-test-id="input-password"
type="password"
:disabled="disabled"
autocomplete="off"
/>
</b-form-group>
</b-col>
</b-row>
<hr>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('from.label')"
:description="$t('from.description')"
label-class="text-primary"
>
<b-input
v-model="server.from"
data-test-id="input-sender-address"
type="email"
:disabled="disabled"
autocomplete="off"
/>
</b-form-group>
</b-col>
</b-row>
<hr>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('tlsServerName.label')"
:description="$t('tlsServerName.description')"
label-class="text-primary"
>
<b-input
v-model="server.tlsServerName"
data-test-id="input-tls-server-name"
:disabled="disabled"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:description="$t('tlsInsecure.description')"
class="mt-lg-3"
>
<b-form-checkbox
v-model="server.tlsInsecure"
data-test-id="checkbox-allow-invalid-certificates"
:disabled="disabled"
class="mt-lg-4 mb-2"
>
{{ $t('tlsInsecure.label') }}
</b-form-checkbox>
</b-form-group>
</b-col>
</b-row>
</b-form>
<template #footer>
<c-button-submit
data-test-id="button-smtp"
:disabled="disabled"
:processing="processingSMTPTest"
:success="successSMTPTest"
:text="$t('testSmtpConfigs.button')"
variant="light"
@submit="smtpConnectionCheck()"
/>
<c-button-submit
:disabled="disabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="submit()"
/>
</template>
</b-card>
</template>
<script>
import Vue from 'vue'
export default {
name: 'CComposeEditorBasic',
i18nOptions: {
namespaces: 'system.email',
keyPrefix: 'editor.server',
},
props: {
value: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
processingSMTPTest: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
successSMTPTest: {
type: Boolean,
value: false,
},
disabled: {
type: Boolean,
value: true,
},
},
data () {
return {
server: Vue.util.extend({
host: '',
port: 25,
user: '',
pass: '',
from: '',
tlsInsecure: false,
tlsServerName: '',
}, this.value),
}
},
methods: {
submit () {
this.$emit('submit', this.server)
},
smtpConnectionCheck () {
this.$emit('smtpConnectionCheck', this.server)
},
},
}
</script>

View File

@@ -0,0 +1,142 @@
const props = {
// OIDC
title: 'Title',
value: {
enabled: true,
handle: 'Handle',
issuer: 'Mara',
key: 'key',
secret: 'shhhhh',
cert: 'cert himself',
idp: {
url: 'https://kati.com',
'ident-name': 'kati',
'ident-handle': 'kati',
'ident-identifier': 'kati',
},
},
}
const controls = [
{
label: 'Title',
type: 'b-form-input',
value: props.title,
handle: (props, value) => {
props.title = value
},
},
{
label: 'Enabled',
type: 'b-form-checkbox',
value: props.value.enabled,
handle: (props, value) => {
props.enabled = value
},
},
{
label: 'Handle',
type: 'b-form-input',
value: props.value.handle,
handle: (props, value) => {
props.handle = value
},
},
{
label: 'Issuer',
type: 'b-form-input',
value: props.value.issuer,
handle: (props, value) => {
props.issuer = value
},
},
{
label: 'Key',
type: 'b-form-input',
value: props.value.key,
handle: (props, value) => {
props.key = value
},
},
{
label: 'Secret',
type: 'b-form-input',
value: props.value.secret,
handle: (props, value) => {
props.secret = value
},
},
{
label: 'Cert',
type: 'b-form-input',
value: props.value.cert,
handle: (props, value) => {
props.cert = value
},
},
{
label: 'url',
type: 'b-form-input',
value: props.value.idp.url,
handle: (props, value) => {
props.value.idp.url = value
},
},
{
label: 'Name',
type: 'b-form-input',
value: props.value.idp['ident-name'],
handle: (props, value) => {
props.value.idp['ident-name'] = value
},
},
{
label: 'Handle',
type: 'b-form-input',
value: props.value.idp['ident-handle'],
handle: (props, value) => {
props.value.idp['ident-handle'] = value
},
},
{
label: 'Identifier',
type: 'b-form-input',
value: props.value.idp['ident-identifier'],
handle: (props, value) => {
props.value.idp['ident-handle'] = value
},
},
]
const scenarios = [
{
label: 'full form',
props,
},
{
label: 'empty form',
props: {
title: 'null',
value: {
enabled: true,
handle: 'null',
issuer: 'null',
key: 'null',
secret: 'null',
cert: 'null',
idp: {
url: 'null',
'ident-name': 'null',
'ident-handle': 'null',
'ident-identifier': 'null',
},
},
},
},
]
export default {
props,
controls,
scenarios,
}

View File

@@ -0,0 +1,135 @@
<template>
<div>
<b-form-group>
<b-form-checkbox
v-model="value.enabled"
:value="true"
:unchecked-value="false"
>
{{ $t('enabled') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
:label="$t('handle')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model.trim="value.handle"
:formatter="alphanum"
:disabled="!fresh"
/>
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('issuer')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model.trim="value.issuer"
placeholder="https://issuer.tld"
/>
</b-input-group>
<b-form-text
v-html="$t('issuerHint')"
/>
</b-form-group>
<b-form-group
:label="$t('clientKey')"
label-class="text-primary"
>
<b-input-group>
<b-form-input v-model.trim="value.key" />
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('clientSecret')"
label-class="text-primary"
>
<b-input-group>
<b-form-input v-model.trim="value.secret" />
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('scope')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model.trim="value.scope"
:placeholder="$t('scopePlaceholder')"
/>
</b-input-group>
<b-form-text
v-html="$t('scopeHint')"
/>
</b-form-group>
<security
v-model="value.security"
/>
</div>
</template>
<script>
import Security from './ExternalSecurity'
export default {
name: 'OIDCExternalAuthProvider',
i18nOptions: {
namespaces: 'system.settings',
keyPrefix: 'editor.external.oidc',
},
components: {
Security,
},
props: {
value: {
type: Object,
required: true,
},
},
data () {
const {
enabled,
key,
secret,
scope,
} = this.value
return {
enabled,
key,
secret,
scope,
permittedRoles: [],
prohibitedRoles: [],
forcedRoles: [],
}
},
computed: {
fresh () {
return Object.prototype.hasOwnProperty.call(this.value, 'fresh') && this.value.fresh
},
},
methods: {
alphanum (v) {
return v.replace(/[^a-zA-Z0-9\-_]+/, '')
},
},
}
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,201 @@
<template>
<div>
<b-form-group>
<b-form-checkbox
v-model="value.enabled"
:value="true"
:unchecked-value="false"
>
{{ $t('enabled') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
:label="$t('name')"
:description="$t('desc.name')"
label-class="text-primary"
>
<b-input-group>
<b-form-input v-model.trim="value.name" />
</b-input-group>
</b-form-group>
<hr>
<h5>
{{ $t('certificate') }}
</h5>
<b-form-group
:label="$t('cert.public')"
:description="$t('desc.cert.public')"
label-class="text-primary"
>
<b-input-group>
<b-form-textarea
v-model.trim="value.cert"
/>
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('cert.private')"
:description="$t('desc.cert.private')"
label-class="text-primary"
>
<b-input-group>
<b-form-textarea v-model.trim="value.key" />
</b-input-group>
</b-form-group>
<hr>
<h5>
{{ $t('requests.title') }}
</h5>
<b-form-group
:description="$t('desc.requests.sign-requests')"
>
<b-form-checkbox
v-model="value['sign-requests']"
>
{{ $t('requests.sign-requests') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
:label="$t('requests.sign-method')"
:description="$t('desc.requests.sign-method')"
label-class="text-primary"
>
<b-input-group>
<b-form-select
v-model.trim="value['sign-method']"
:options="signMethods"
>
<template #first>
<b-form-select-option
value=""
disabled
>
{{ $t('admin:general.label.selectOption') }}
</b-form-select-option>
</template>
</b-form-select>
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('requests.binding')"
:description="$t('desc.requests.binding')"
label-class="text-primary"
>
<b-input-group>
<b-form-select
v-model.trim="value['binding']"
:options="httpBindings"
>
<template #first>
<b-form-select-option
value=""
disabled
>
{{ $t('admin:general.label.selectOption') }}
</b-form-select-option>
</template>
</b-form-select>
</b-input-group>
</b-form-group>
<hr>
<h5>
{{ $t('idp.title') }}
</h5>
<b-form-group
:label="$t('idp.url')"
:description="$t('desc.idp.url')"
label-class="text-primary"
>
<b-input-group>
<b-form-input v-model.trim="value.idp.url" />
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('idp.ident-name')"
:description="$t('desc.idp.ident-name')"
label-class="text-primary"
>
<b-input-group>
<b-form-input v-model.trim="value.idp['ident-name']" />
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('idp.ident-handle')"
:description="$t('desc.idp.ident-handle')"
label-class="text-primary"
>
<b-input-group>
<b-form-input v-model.trim="value.idp['ident-handle']" />
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('idp.ident-identifier')"
:description="$t('desc.idp.ident-identifier')"
label-class="text-primary"
>
<b-input-group>
<b-form-input v-model.trim="value.idp['ident-identifier']" />
</b-input-group>
</b-form-group>
<security
v-model="value.security"
/>
</div>
</template>
<script>
import Security from './ExternalSecurity'
export default {
name: 'SamlExternalAuthProvider',
i18nOptions: {
namespaces: 'system.settings',
keyPrefix: 'editor.external.saml',
},
components: {
Security,
},
props: {
value: {
type: Object,
required: true,
default: () => ({}),
},
},
data () {
return {
signMethods: [
{ value: 'http://www.w3.org/2000/09/xmldsig#rsa-sha1', text: 'SHA1' },
{ value: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', text: 'SHA256' },
{ value: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512', text: 'SHA512' },
],
httpBindings: [
{ value: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', text: this.$t('requests.binding-post') },
{ value: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', text: this.$t('requests.binding-redirect') },
],
}
},
}
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,78 @@
<template>
<div>
<hr>
<h5>
{{ $t('title') }}
</h5>
<b-form-group
:label="$t('permitted-roles.label')"
label-class="text-primary"
class="mb-0"
>
<c-role-picker
v-model="value.permittedRoles"
class="mb-3"
>
<template #description>
{{ $t('permitted-roles.description') }}
</template>
</c-role-picker>
</b-form-group>
<b-form-group
:label="$t('prohibited-roles.label')"
label-class="text-primary"
class="mb-0"
>
<c-role-picker
v-model="value.prohibitedRoles"
class="mb-3"
>
<template #description>
{{ $t('prohibited-roles.description') }}
</template>
</c-role-picker>
</b-form-group>
<b-form-group
:label="$t('forced-roles.label')"
label-class="text-primary"
class="mb-0"
>
<c-role-picker
v-model="value.forcedRoles"
class="mb-3"
>
<template #description>
{{ $t('forced-roles.description') }}
</template>
</c-role-picker>
</b-form-group>
</div>
</template>
<script>
import CRolePicker from 'corteza-webapp-admin/src/components/CRolePicker'
export default {
name: 'StandardExternalAuthProvider',
i18nOptions: {
namespaces: 'system.settings',
keyPrefix: 'editor.external.security',
},
components: {
CRolePicker,
},
props: {
value: {
type: Object,
required: true,
default: () => ({}),
},
},
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div>
<b-form-group>
<b-form-checkbox
v-model="value.enabled"
>
{{ $t('enabled') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
:label="$t('clientKey')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model.trim="value.key"
:required="value.enabled"
/>
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('clientSecret')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model.trim="value.secret"
:required="value.enabled"
/>
</b-input-group>
</b-form-group>
<security
v-model="value.security"
/>
</div>
</template>
<script>
import Security from './ExternalSecurity'
export default {
name: 'StandardExternalAuthProvider',
i18nOptions: {
namespaces: 'system.settings',
keyPrefix: 'editor.external.standard',
},
components: {
Security,
},
props: {
value: {
type: Object,
required: true,
default: () => ({}),
},
},
computed: {
// providerUsage provides a temporary checkbox implementation until something
// more proper is done
//
// When checked (true), we consider it as wanting to use the API
providerUsage: {
get () {
return ((this.value || {}).usage || []).includes('api')
},
set (value) {
this.value.usage = value ? ['api'] : []
},
},
},
}
</script>

View File

@@ -0,0 +1,43 @@
import { default as component } from './CSystemEditorAuth.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
settings: {
'auth.internal.enabled': true,
'auth.internal.signup.enabled': true,
},
processing: false,
success: false,
canManage: true,
}
export default {
name: 'System editor auth',
group: ['Settings'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
checkbox('CanManage', 'canManage'),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
settings: {
'auth.internal.enabled': false,
'auth.internal.signup.enabled': false,
},
canManage: false,
},
},
],
}

View File

@@ -0,0 +1,607 @@
<template>
<b-card
data-test-id="card-edit-authentication"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
@submit.prevent="$emit('submit', authSettings)"
>
<h5>
{{ $t('internal.title') }}
</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.enabled')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.internal.enabled']"
switch
:value="true"
:unchecked-value="false"
:labels="checkboxLabel"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.password-reset.enabled')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.internal.password-reset.enabled']"
switch
data-test-id="checkbox-password-reset"
:value="true"
:labels="checkboxLabel"
:unchecked-value="false"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.signup.email-confirmation-required')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.internal.signup.email-confirmation-required']"
switch
:value="true"
:labels="checkboxLabel"
:unchecked-value="false"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.signup.enabled')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.internal.signup.enabled']"
:value="true"
:unchecked-value="false"
:labels="checkboxLabel"
switch
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.profile-avatar.enabled')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.internal.profile-avatar.enabled']"
:value="true"
:unchecked-value="false"
:labels="checkboxLabel"
switch
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.signup.split-credentials-check.label')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.internal.split-credentials-check']"
:value="true"
:unchecked-value="false"
:labels="checkboxLabel"
switch
/>
</b-form-group>
</b-col>
</b-row>
<hr>
<div>
<h5>
{{ $t('internal.password-constraints.title') }}
</h5>
<b-alert
v-if="!$Settings.get('auth.internal.passwordConstraints.passwordSecurity')"
variant="warning"
show
>
{{ $t('internal.password-constraints.ignored-security') }}
</b-alert>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.password-constraints.min-upper-case-length')"
:description="$t('internal.password-constraints.min-upper-case-description')"
label-class="text-primary"
>
<b-form-input
v-model.number="authSettings['auth.internal.password-constraints.min-upper-case']"
type="number"
:placeholder="`${defaultMinUppCaseChrs}`"
:min="defaultMinUppCaseChrs"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.password-constraints.min-lower-case-length')"
:description="$t('internal.password-constraints.min-lower-case-description')"
label-class="text-primary"
>
<b-form-input
v-model.number="authSettings['auth.internal.password-constraints.min-lower-case']"
type="number"
:placeholder="`${defaultMinLowCaseChrs}`"
:min="defaultMinLowCaseChrs"
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.password-constraints.min-length')"
:description="$t('internal.password-constraints.min-length-description')"
label-class="text-primary"
>
<b-form-input
v-model.number="authSettings['auth.internal.password-constraints.min-length']"
:placeholder="`${defaultMinPwd}`"
:min="defaultMinPwd"
type="number"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.password-constraints.min-num-count')"
:description="$t('internal.password-constraints.min-num-count-description')"
label-class="text-primary"
>
<b-form-input
v-model.number="authSettings['auth.internal.password-constraints.min-num-count']"
placeholder="0"
min="0"
type="number"
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.password-constraints.min-special-count')"
:description="$t('internal.password-constraints.min-special-count-description')"
label-class="text-primary"
>
<b-form-input
v-model.number="authSettings['auth.internal.password-constraints.min-special-count']"
placeholder="0"
min="0"
type="number"
/>
</b-form-group>
</b-col>
</b-row>
</div>
<hr>
<div>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('mfa.emailOTP.enabled')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.multi-factor.email-otp.enabled']"
data-test-id="checkbox-enable-emailOTP"
:value="true"
:unchecked-value="false"
:labels="checkboxLabel"
switch
@change="handleEmailOTPEnabledChange"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('mfa.emailOTP.expires.label')"
:description="$t('mfa.emailOTP.expires.description')"
label-class="text-primary"
>
<b-input-group append="seconds">
<b-form-input
v-model="authSettings['auth.multi-factor.email-otp.expires']"
type="number"
placeholder="60"
/>
</b-input-group>
</b-form-group>
</b-col>
<b-col
v-if="authSettings['auth.multi-factor.email-otp.enabled']"
cols="12"
lg="6"
>
<b-form-group
:label="$t('mfa.emailOTP.enforced')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.multi-factor.email-otp.enforced']"
:value="true"
:unchecked-value="false"
:labels="checkboxLabel"
:disabled="!authSettings['auth.multi-factor.email-otp.enabled']"
switch
@change="handleEmailOTPEnforcedChange"
/>
</b-form-group>
</b-col>
</b-row>
</div>
<hr>
<div>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('mfa.TOTP.enabled')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.multi-factor.totp.enabled']"
data-test-id="checkbox-enable-TOTP"
:value="true"
:unchecked-value="false"
:labels="checkboxLabel"
switch
@change="handleTOTPEnabledChange"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('mfa.TOTP.issuer.label')"
:description="$t('mfa.TOTP.issuer.description')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model="authSettings['auth.multi-factor.totp.issuer']"
placeholder="Corteza"
/>
</b-input-group>
</b-form-group>
</b-col>
<b-col
v-if="authSettings['auth.multi-factor.totp.enabled']"
cols="12"
lg="6"
>
<b-form-group
:label="$t('mfa.TOTP.enforced')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.multi-factor.totp.enforced']"
:value="true"
:unchecked-value="false"
:labels="checkboxLabel"
:disabled="!authSettings['auth.multi-factor.totp.enabled']"
switch
@change="handleTOTPEnforcedChange"
/>
</b-form-group>
</b-col>
</b-row>
</div>
<hr>
<div>
<h5>
{{ $t('mail.title') }}
</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('mail.from-address')"
:description="$t('mail.validate-email')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model="authSettings['auth.mail.from-address']"
type="email"
/>
</b-input-group>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('mail.from-name')"
label-class="text-primary"
>
<b-input-group>
<b-form-input v-model="authSettings['auth.mail.from-name']" />
</b-input-group>
</b-form-group>
</b-col>
</b-row>
</div>
<hr>
<div>
<h5>
{{ $t('internal.send-user-invite-email.title') }}
</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.send-user-invite-email.enabled')"
:description="$t('internal.send-user-invite-email.description')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.internal.send-user-invite-email.enabled']"
:value="true"
:unchecked-value="false"
:labels="checkboxLabel"
switch
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('internal.send-user-invite-email.expires.label')"
:description="$t('internal.send-user-invite-email.expires.description')"
label-class="text-primary"
>
<b-input-group append="hours">
<b-form-input
v-model="authSettings['auth.internal.send-user-invite-email.expires']"
type="number"
/>
</b-input-group>
</b-form-group>
</b-col>
</b-row>
</div>
<hr>
<div>
<h5>
{{ $t('auto-logout.title') }}
</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('auto-logout.enabled.label')"
:description="$t('auto-logout.enabled.description')"
label-class="text-primary"
>
<c-input-checkbox
v-model="authSettings['auth.auto-logout.enabled']"
:value="true"
:unchecked-value="false"
:labels="checkboxLabel"
switch
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('auto-logout.timeout.label')"
:description="$t('auto-logout.timeout.description')"
label-class="text-primary"
>
<b-input-group append="seconds">
<b-form-input
v-model="authSettings['auth.auto-logout.timeout']"
type="number"
/>
</b-input-group>
</b-form-group>
</b-col>
</b-row>
</div>
</b-form>
<template #footer>
<c-button-submit
v-if="canManage"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', authSettings)"
/>
</template>
</b-card>
</template>
<script>
export default {
name: 'CSystemEditorAuth',
i18nOptions: {
namespaces: 'system.settings',
keyPrefix: 'editor.auth',
},
props: {
settings: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canManage: {
type: Boolean,
required: true,
},
},
data () {
return {
authSettings: {},
defaultMinPwd: 8,
defaultMinUppCaseChrs: 0,
defaultMinLowCaseChrs: 0,
checkboxLabel: {
on: this.$t('general:label.general.yes'),
off: this.$t('general:label.general.no'),
},
}
},
watch: {
settings: {
immediate: true,
deep: true,
handler () {
this.authSettings = this.settings
},
},
},
methods: {
handleEmailOTPEnabledChange (value) {
if (!value) {
this.authSettings['auth.multi-factor.email-otp.enforced'] = false
}
},
handleTOTPEnabledChange (value) {
if (!value) {
this.authSettings['auth.multi-factor.totp.enforced'] = false
}
},
handleEmailOTPEnforcedChange (value) {
if (value) {
this.authSettings['auth.multi-factor.email-otp.enforced'] = true
}
},
handleTOTPEnforcedChange (value) {
if (value) {
this.authSettings['auth.multi-factor.totp.enforced'] = true
}
},
},
}
</script>

View File

@@ -0,0 +1,148 @@
<template>
<b-card
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t("title") }}
</h4>
</template>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
label-class="d-flex align-items-center text-primary"
>
<template #label>
{{ $t('image.uploader.label') }}
<c-input-confirm
v-if="uploadedFile('auth.ui.background-image-src')"
show-icon
class="ml-auto"
@confirmed="$emit('resetAttachment', 'auth.ui.background-image-src')"
/>
</template>
<c-uploader-with-preview
:value="uploadedFile('auth.ui.background-image-src')"
endpoint="/settings/auth.ui.background-image-src"
:disabled="!canManage"
@upload="$emit('onUpload')"
@clear="$emit('resetAttachment', 'auth.ui.background-image-src')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
>
<b-form-group
:label="$t('image.editor.label')"
label-class="d-flex align-items-center text-primary"
>
<c-ace-editor
v-model="settings['auth.ui.styles']"
data-test-id="auth-bg-image-styling-editor"
name="editor/css"
lang="css"
min-height="500px"
show-line-numbers
auto-complete
:auto-complete-suggestions="customCssAutocompleteVal"
resizable
class="flex-fill w-100"
/>
</b-form-group>
</b-col>
</b-row>
<template #footer>
<c-button-submit
v-if="canManage"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', settings['auth.ui.styles'])"
/>
</template>
</b-card>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
import { CUSTOM_CSS_AUTO_COMPLETE_VALUES } from 'corteza-webapp-admin/src/lib/cssAutoComplete'
import CUploaderWithPreview from 'corteza-webapp-admin/src/components/CUploaderWithPreview'
const { CAceEditor } = components
export default {
name: 'CSystemEditorAuthBgImage',
i18nOptions: {
namespaces: 'system.settings',
keyPrefix: 'editor.bgScreen',
},
components: {
CUploaderWithPreview,
CAceEditor,
},
props: {
settings: {
type: Object,
required: true,
},
canManage: {
type: Boolean,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
},
data () {
return {
customCssAutocompleteVal: CUSTOM_CSS_AUTO_COMPLETE_VALUES,
}
},
methods: {
uploadedFile (name) {
const localAttachment = /^attachment:(\d+)/
switch (true) {
case this.settings[name] && localAttachment.test(this.settings[name]):
const [, attachmentID] = localAttachment.exec(this.settings[name])
return (
this.$SystemAPI.baseURL +
this.$SystemAPI.attachmentOriginalEndpoint({
attachmentID,
kind: 'settings',
name,
})
)
}
return undefined
},
},
}
</script>

View File

@@ -0,0 +1,516 @@
<template>
<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"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<div class="d-flex align-items-center flex-grow-1 flex-wrap flex-fill-child gap-1 p-3">
<b-button
variant="primary"
size="lg"
@click="newOIDC()"
>
{{ $t('oidc.add') }}
</b-button>
<b-form-group
:label="$t('enabled')"
label-class="text-primary"
class="mb-0 ml-auto"
>
<c-input-checkbox
v-model="external.enabled"
:value="true"
:unchecked-value="false"
:labels="checkboxLabel"
switch
/>
</b-form-group>
</div>
<b-table
:items="providerItems"
:fields="providerFields"
:tbody-tr-class="(i) => i.rowBackground"
head-variant="light"
hover
class="mb-0"
>
<template #cell(enabled)="{ item }">
<b-checkbox
:checked="item.enabled"
@change="item.enable($event)"
/>
</template>
<template #cell(provider)="{ item }">
{{ item.provider || item.tag }}
</template>
<template #cell(editor)="{ item }">
<c-input-confirm
v-if="item.delete"
:icon="item.deleted ? ['fas', 'trash-restore'] : undefined"
:variant="item.deleted ? 'outline-warning' : 'outline-danger'"
:variant-ok="item.deleted ? 'warning' : 'danger'"
@confirmed="item.delete()"
/>
<b-button
variant="link"
@click="openEditor(item.editor)"
>
<font-awesome-icon
:icon="['fas', 'wrench']"
/>
</b-button>
</template>
</b-table>
<b-modal
v-model="modal.open"
:title="modal.title"
scrollable
size="lg"
title-class="text-capitalize"
@ok="modal.updater(modal.data)"
>
<component
:is="modal.component"
v-model="modal.data"
/>
</b-modal>
<template #footer>
<c-button-submit
:disabled="!dirty || !canManage"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', changes)"
/>
</template>
</b-card>
</template>
<script>
import _ from 'lodash'
import OidcExternal from 'corteza-webapp-admin/src/components/Settings/System/Auth/ExternalOIDC'
import StandardExternal from 'corteza-webapp-admin/src/components/Settings/System/Auth/ExternalStd'
import SamlExternal from 'corteza-webapp-admin/src/components/Settings/System/Auth/ExternalSAML'
const idpStandard = [
'google',
'github',
'facebook',
'linkedin',
]
const idpSecurity = {
permittedRoles: [],
prohibitedRoles: [],
forcedRoles: [],
}
/**
* Give some structure to key-val settings
*/
function prepareExternal (external) {
const extractKey = (name, t = 'string') => {
const v = external.find(v => v.name === `auth.external.${name}`)
switch (t) {
case 'string':
return (v || { value: null }).value || ''
case 'boolean':
return !!(v || { value: null }).value
case 'array':
return (v || { value: [] }).value || []
case undefined:
// use t=undefined to get raw value
return v.value
}
}
const extractKeys = (provider, base = {}) => {
const out = { ...base }
for (const k in base) {
out[k] = extractKey(`providers.${provider}.${k}`, Array.isArray(out[k]) ? 'array' : typeof out[k])
}
return out
}
// careful with prefix!
// ....providers.openid-connect....
// ....providers....
// ....saml
const extractSec = (prefix) => {
return { ...idpSecurity, ...(extractKey(`${prefix}.security`, undefined) || {}) }
}
const data = {
enabled: !!(external.find(v => v.name === 'auth.external.enabled') || {}).value,
oidc: [],
standard: [],
saml: {
enabled: extractKey('saml.enabled'),
cert: extractKey('saml.cert'),
name: extractKey('saml.name'),
key: extractKey('saml.key'),
'sign-method': extractKey('saml.sign-method'),
'sign-requests': extractKey('saml.sign-requests', 'boolean'),
binding: extractKey('saml.binding'),
idp: {
url: extractKey('saml.idp.url'),
'ident-name': extractKey('saml.idp.ident-name'),
'ident-handle': extractKey('saml.idp.ident-handle'),
'ident-identifier': extractKey('saml.idp.ident-identifier'),
},
security: extractSec('saml'),
},
}
data.standard = idpStandard.map(handle => ({
handle,
...extractKeys(handle, {
enabled: false,
secret: '',
key: '',
security: {},
usage: [],
}),
security: extractSec(`providers.${handle}`),
}))
const prefix = 'auth.external.providers.openid-connect.'
data.oidc =
[...new Set(external
// Filter out all keys that start with prefix
.filter(v => v.name.indexOf(prefix) === 0)
// trim off the prefix and everything after next.
.map(({ name }) => name.substring(prefix.length).split('.', 2)[0]))]
.map(handle => ({
...extractKeys('openid-connect.' + handle, {
enabled: false,
issuer: '',
key: '',
secret: '',
scope: '',
security: {},
}),
handle,
security: extractSec('providers.openid-connect.' + handle),
deleted: false,
}))
return data
}
export default {
name: 'CSystemEditorExternal',
i18nOptions: {
namespaces: 'system.settings',
keyPrefix: 'editor.external',
},
components: {
OidcExternal,
StandardExternal,
SamlExternal,
},
props: {
value: {
type: Array,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canManage: {
type: Boolean,
required: true,
},
},
data () {
return {
// current open modal
modal: {
open: false,
editor: null,
title: null,
data: null,
},
// working copy
external: prepareExternal(this.value),
roles: [],
checkboxLabel: {
on: this.$t('general:label.general.yes'),
off: this.$t('general:label.general.no'),
},
}
},
computed: {
dirty () {
return this.changes.length > 0
},
// copy of all values for comparison
original () {
return Object.freeze(prepareExternal(this.value))
},
/**
* Configuration for table that shows external providers
*
* @returns object
*/
providerFields () {
return [
{ key: 'enabled', label: this.$t('table.header.enabled'), thStyle: { width: '50px' } },
{ key: 'provider', label: this.$t('table.header.provider'), thStyle: { width: '200px' }, tdClass: 'text-capitalize' },
{ key: 'info', label: this.$t('table.header.info') },
{ key: 'editor', label: '', thStyle: { width: '200px' }, tdClass: 'text-right' },
]
},
providerItems () {
return [
{
rowBackground: _.isEqual(this.original.saml, this.external.saml) ? '' : 'bg-extra-light',
provider: this.external.saml.name,
info: this.external.saml.idp.url,
tag: 'SAML',
enabled: this.external.saml.enabled,
enable: (val) => this.$set(this.external.saml, 'enabled', val),
editor: {
component: 'saml-external',
data: this.external.saml,
title: this.$t('saml.title'),
updater: (changed) => this.updater('saml', changed),
},
},
...this.external.oidc
.map((p, i) => ({
rowBackground: (() => {
if (_.isEqual(this.original.oidc[i], p)) {
return ''
}
if (p.deleted) {
return 'text-extra-light deleted'
}
return 'bg-extra-light'
})(),
provider: p.handle,
tag: 'OIDC',
info: p.issuer,
enabled: p.enabled,
deleted: p.deleted,
enable: (val) => this.$set(this.external.oidc[i], 'enabled', val),
delete: () => {
this.$set(this.external.oidc[i], 'deleted', !p.deleted)
},
editor: {
component: 'oidc-external',
data: p,
title: p.handle,
updater: (changed) => this.updater('oidc', changed, i),
},
})),
...this.external.standard.map((p, i) => ({
rowBackground: _.isEqual(this.original.standard[i], p) ? '' : 'bg-extra-light',
provider: p.handle,
info: p.key,
enabled: p.enabled,
enable: (val) => this.$set(this.external.standard[i], 'enabled', val),
editor: {
component: 'standard-external',
data: p,
title: p.handle,
updater: (changed) => this.updater('standard', changed, i),
},
})),
]
},
/**
* Converts changed settings back to values
*/
changes () {
let name, value
const c = []
const prefix = 'auth.external.providers'
const o = this.original
const e = this.external
if (!_.isEqual(o.enabled, e.enabled)) {
c.push({ name: 'auth.external.enabled', value: e.enabled })
}
/**
* General purpose key mapper
*/
const mapKeys = (prefix, wc, org, keys) => {
for (const k of keys) {
if (wc[k] === undefined) {
console.error(`potential issue - unknown key "${prefix}.${k}" used`, wc)
}
if (_.isEqual(wc[k], org[k])) {
continue
}
name = `${prefix}.${k}`
value = wc[k]
c.push({ name, value })
}
}
e.standard.forEach((p, i) => {
mapKeys(
`${prefix}.${p.handle}`,
p,
o.standard[i],
['key', 'secret', 'enabled', 'security', 'usage'],
)
})
// @todo how do we remove OIDC?
const oidcKeys = ['key', 'secret', 'enabled', 'issuer', 'scope', 'security']
e.oidc.forEach((p, i) => {
if (p.deleted) {
// use base set of keys used for OIDC provider
// plus add a few extra ones that are usually set by the backend
//
// @todo it would probably smarter to trigger the backend that all settings
// with a certain key prefix should be deleted.
// Unfortunately, backend does not support that for now + could be a bit dangerous
[...oidcKeys, 'weight', 'redirect', 'label']
.forEach(name => c.push({ name: `${prefix}.openid-connect.${p.handle}.${name}`, value: null }))
} else {
mapKeys(
`${prefix}.openid-connect.${p.handle}`,
p,
o.oidc[i] || {},
oidcKeys,
)
}
})
mapKeys(
'auth.external.saml',
e.saml,
o.saml,
['enabled', 'name', 'key', 'cert', 'sign-method', 'sign-requests', 'binding', 'security'],
)
mapKeys(
'auth.external.saml.idp',
e.saml.idp,
o.saml.idp,
['url', 'ident-name', 'ident-handle', 'ident-identifier'],
)
return c
},
},
watch: {
value: {
immediate: true,
handler () {
this.external = prepareExternal(this.value)
},
},
},
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 = JSON.parse(JSON.stringify(data))
},
newOIDC () {
this.openEditor({
component: 'oidc-external',
title: this.$t('oidc.add'),
data: {
handle: '',
enabled: true,
issuer: '',
key: '',
secret: '',
scope: '',
fresh: true,
security: { ...idpSecurity },
},
updater: (changed) => {
this.updater('oidc', changed, -1)
},
})
},
/**
* Please note that we're assuming that array items will not get changed!
*
* @param key
* @param i
* @param val
*/
updater (key, val, i = undefined) {
if (i === undefined) {
this.$set(this.external, key, val)
} else if (i < 0) {
this.external[key].push(val)
} else {
this.$set(this.external[key], i, val)
}
},
},
}
</script>
<style lang="scss">
.deleted {
text-decoration: line-through;
}
</style>

View File

@@ -0,0 +1,406 @@
<template>
<b-card
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
body-class="p-0"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<div
v-if="!sassInstalled"
class="bg-warning rounded p-2 mb-3"
>
{{ $t('sassNotInstalled') }}
<a
:href="installSassDocs"
target="_blank"
class="text-dark"
>
{{ $t('installSassDocs') }}
</a>
</div>
<b-tabs
data-test-id="theme-tabs"
card
>
<b-tab
v-for="theme in themes"
:key="theme.id"
:title="theme.title"
>
<b-row v-if="theme.id !== 'general'">
<b-col
v-for="key in themeVariables"
:key="key"
cols="12"
lg="6"
>
<b-form-group
:label="$t(`theme.variables.${key}.label`)"
label-class="text-primary"
:description="$t(`theme.variables.${key}.description`)"
>
<c-input-color-picker
ref="picker"
v-model="theme.variables[key]"
:default-value="theme.defaultVariables[key]"
:data-test-id="`input-${key}-color`"
:translations="{
modalTitle: $t('colorPicker'),
defaultBtnLabel: $t('label.default'),
light: $t('tabs.light'),
dark: $t('tabs.dark'),
cancelBtnLabel: $t('general:label.cancel'),
saveBtnLabel: $t('general:label.saveAndClose')
}"
:theme-settings="settings['ui.studio.themes']"
/>
</b-form-group>
</b-col>
</b-row>
<b-row v-else>
<b-col
cols="12"
lg="6"
>
<b-form-group
label-class="d-flex align-items-center text-primary"
>
<template #label>
{{ $t('mainLogo.title') }}
<c-input-confirm
v-if="uploadedFile('ui.main-logo')"
show-icon
class="ml-auto"
@confirmed="resetAttachment('ui.main-logo')"
/>
</template>
<c-uploader-with-preview
:value="uploadedFile('ui.main-logo')"
endpoint="/settings/ui.main-logo"
:disabled="!canManage"
@upload="onUpload"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
label-class="d-flex align-items-center text-primary h-lg-100"
>
<template #label>
{{ $t('iconLogo.title') }}
<c-input-confirm
v-if="uploadedFile('ui.icon-logo')"
show-icon
class="ml-auto"
@confirmed="resetAttachment('ui.icon-logo')"
/>
</template>
<c-uploader-with-preview
:value="uploadedFile('ui.icon-logo')"
endpoint="/settings/ui.icon-logo"
:disabled="!canManage"
@upload="onUpload"
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col>
<b-form-group
:label="$t('custom-css')"
label-class="text-primary"
class="mb-0"
>
<c-ace-editor
v-model="theme.customCSS"
auto-complete
lang="css"
min-height="400px"
show-line-numbers
:show-popout="true"
:auto-complete-suggestions="customCssAutocompleteVal"
@open="openCustomCSSModal(theme.id)"
/>
</b-form-group>
</b-col>
</b-row>
</b-tab>
</b-tabs>
<b-modal
id="custom-css-editor"
v-model="customCSSModal.show"
:title="$t('custom-css')"
cancel-variant="light"
size="xl"
scrollable
:ok-title="$t('general:label.saveAndClose')"
:cancel-title="$t('general:label.cancel')"
body-class="p-0"
@ok="saveCustomCSSModal()"
@hidden="resetCustomCSSModal()"
>
<c-ace-editor
v-model="customCSSModal.value"
auto-complete
lang="css"
min-height="80vh"
show-line-numbers
:border="false"
:show-popout="false"
:auto-complete-suggestions="customCssAutocompleteVal"
/>
</b-modal>
<template #footer>
<c-button-submit
v-if="canManage"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="onSubmit"
/>
</template>
</b-card>
</template>
<script>
import CUploaderWithPreview from 'corteza-webapp-admin/src/components//CUploaderWithPreview'
import { components } from '@cortezaproject/corteza-vue'
import { CUSTOM_CSS_AUTO_COMPLETE_VALUES } from 'corteza-webapp-admin/src/lib/cssAutoComplete'
const { CInputColorPicker, CAceEditor } = components
export default {
name: 'CUIBrandingEditor',
i18nOptions: {
namespaces: 'ui.settings',
keyPrefix: 'editor.corteza-studio',
},
components: {
CUploaderWithPreview,
CInputColorPicker,
CAceEditor,
},
props: {
settings: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canManage: {
type: Boolean,
required: true,
},
},
data () {
return {
themeTabs: [
'general',
'light',
'dark',
],
themeVariables: [
'black',
'white',
'primary',
'secondary',
'success',
'warning',
'danger',
'light',
'extra-light',
'body-bg',
'sidebar-bg',
'topbar-bg',
],
lightModeVariables: {
black: '#0B344E',
white: '#FFFFFF',
primary: '#FF9661',
secondary: '#758D9B',
success: '#43AA8B',
warning: '#E27646',
danger: '#E54122',
light: '#F3F5F7',
'extra-light': '#E4E9EF',
'body-bg': '#F3F5F7',
'sidebar-bg': '#FFFFFF',
'topbar-bg': '#F3F5F7',
},
darkModeVariables: {
black: '#FBF7F4',
white: '#0B344E',
primary: '#FF9661',
secondary: '#758D9B',
success: '#43AA8B',
warning: '#E27646',
danger: '#E54122',
light: '#23495F',
'extra-light': '#3E5A6F',
'body-bg': '#092B40',
'sidebar-bg': '#0B344E',
'topbar-bg': '#092B40',
},
themes: [],
themeColors: [],
customCSSModal: {
show: false,
id: '',
value: '',
},
customCssAutocompleteVal: CUSTOM_CSS_AUTO_COMPLETE_VALUES,
}
},
computed: {
sassInstalled () {
return this.settings['ui.studio.sass-installed']
},
installSassDocs () {
// eslint-disable-next-line no-undef
const [year, month] = VERSION.split('.')
return `https://docs.cortezaproject.org/corteza-docs/${year}.${month}/integrator-guide/corteza-studio/index.html`
},
},
watch: {
settings: {
immediate: true,
handler (settings) {
const themes = settings['ui.studio.themes'] || []
const customCSS = settings['ui.studio.custom-css'] || []
this.themes = this.themeTabs.map((id) => {
const { title, values = '' } = themes.find(t => t.id === id) || {}
const defaultCustomCSS = customCSS.find(t => t.id === id) || {}
let variables = JSON.parse(values || '{}')
let defaultVariables
if (['light', 'dark'].includes(id)) {
if (!values) {
variables = id === 'light' ? this.lightModeVariables : this.darkModeVariables
}
defaultVariables = id === 'light' ? this.lightModeVariables : this.darkModeVariables
}
return {
id,
title: title || this.$t(`tabs.${id}`),
variables,
defaultVariables,
customCSS: defaultCustomCSS.values || '',
}
})
},
},
},
methods: {
onSubmit () {
this.$emit('submit', {
'ui.studio.themes': this.themes.map(theme => {
return {
id: theme.id,
title: theme.title,
values: JSON.stringify(theme.variables),
}
}),
'ui.studio.custom-css': this.themes.map(theme => {
return {
id: theme.id,
title: theme.title,
values: theme.customCSS,
}
}),
})
},
openCustomCSSModal (id) {
const { customCSS } = this.themes.find(t => t.id === id) || {}
this.customCSSModal.id = id
this.customCSSModal.value = customCSS
this.customCSSModal.show = true
},
saveCustomCSSModal () {
this.themes.find(t => t.id === this.customCSSModal.id).customCSS = this.customCSSModal.value
},
resetCustomCSSModal () {
this.customCSSModal.id = ''
this.customCSSModal.value = ''
this.customCSSModal.show = false
},
resetColor (key, theme) {
this.$set(theme.variables, key, theme.id === 'light' ? this.lightModeVariables[key] : this.darkModeVariables[key])
},
onUpload ({ name, value }) {
this.$set(this.settings, name, value)
},
resetAttachment (name) {
this.$SystemAPI.settingsUpdate({ values: [{ name, value: undefined }], upload: {} })
.then(() => {
this.$set(this.settings, name, undefined)
})
},
uploadedFile (name) {
const localAttachment = /^attachment:(\d+)/
switch (true) {
case this.settings[name] && localAttachment.test(this.settings[name]):
const [, attachmentID] = localAttachment.exec(this.settings[name])
return this.$SystemAPI.baseURL +
this.$SystemAPI.attachmentOriginalEndpoint({
attachmentID,
kind: 'settings',
name,
})
}
return undefined
},
},
}
</script>

View File

@@ -0,0 +1,155 @@
<template>
<b-card
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('geosearch.title') }}
</h4>
</template>
<b-form
@submit.prevent="$emit('submit', settings)"
>
<b-form-group
:label="$t('geosearch.provider.label')"
:description="$t('geosearch.provider.description')"
label-class="text-primary"
>
<c-input-select
v-model="locationSettings.geoSearchProvider"
:options="providerOptions"
:reduce="o => o.value"
label="text"
:clearable="false"
/>
</b-form-group>
<b-form-group
v-if="requiresApiKey"
:label="$t('geosearch.apiKey.label')"
label-class="text-primary"
>
<b-form-input
v-model="locationSettings.geoSearchApiKey"
type="text"
:placeholder="$t('geosearch.apiKey.placeholder')"
/>
</b-form-group>
</b-form>
<template #footer>
<c-button-submit
v-if="canManage"
:processing="processing"
:success="success"
:disabled="isSubmitDisabled"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="onSubmit"
/>
</template>
</b-card>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CInputSelect } = components
export default {
name: 'CUILocationSettings',
components: {
CInputSelect,
},
i18nOptions: {
namespaces: 'ui.settings',
keyPrefix: 'editor.location',
},
props: {
settings: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canManage: {
type: Boolean,
required: true,
},
},
data () {
return {
locationSettings: {},
providerOptions: [
{ value: 'openstreetmap', text: 'OpenStreetMap', requiresApiKey: false },
{ value: 'opencage', text: 'OpenCage', requiresApiKey: true },
{ value: 'esri', text: 'Esri', requiresApiKey: false },
{ value: 'geoapify', text: 'Geoapify', requiresApiKey: true },
{ value: 'geocodeearth', text: 'Geocode Earth', requiresApiKey: true },
{ value: 'google', text: 'Google Maps', requiresApiKey: true },
{ value: 'locationiq', text: 'LocationIQ', requiresApiKey: true },
{ value: 'mapbox', text: 'Mapbox', requiresApiKey: true },
{ value: 'pelias', text: 'Pelias', requiresApiKey: true },
],
}
},
computed: {
selectedProvider () {
return this.providerOptions.find(p => p.value === this.locationSettings.geoSearchProvider)
},
requiresApiKey () {
return this.selectedProvider?.requiresApiKey || false
},
isSubmitDisabled () {
return this.requiresApiKey && !this.locationSettings.geoSearchApiKey
},
},
watch: {
settings: {
immediate: true,
handler (settings) {
this.locationSettings = settings['ui.location'] || {}
if (!this.locationSettings.geoSearchProvider) {
this.$set(this.locationSettings, 'geoSearchProvider', 'openstreetmap')
}
if (!this.locationSettings.geoSearchApiKey) {
this.$set(this.locationSettings, 'geoSearchApiKey', '')
}
},
},
'locationSettings.geoSearchProvider' (newProvider) {
this.locationSettings.geoSearchApiKey = ''
},
},
methods: {
onSubmit () {
this.$emit('submit', { 'ui.location': this.locationSettings })
},
},
}
</script>

View File

@@ -0,0 +1,350 @@
<template>
<b-card
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
@submit.prevent="$emit('submit', settings)"
>
<h5>{{ $t('general') }}</h5>
<b-form-group>
<b-form-checkbox
v-model="topbarSettings.hideAppSelector"
>
{{ $t('app-selector.hide') }}
</b-form-checkbox>
<b-form-checkbox
v-model="hideDrafts"
>
{{ $t('drafts.hide') }}
<b-badge
variant="warning"
class="ml-1"
>
{{ $t('general:label.experimental') }}
</b-badge>
</b-form-checkbox>
<b-form-checkbox
v-model="topbarSettings.hideNotifications"
>
{{ $t('notifications.hide') }}
</b-form-checkbox>
<b-form-checkbox
v-model="topbarSettings.hideHelp"
>
{{ $t('help.hide') }}
</b-form-checkbox>
<b-form-checkbox
v-if="$Settings.get('discovery.enabled', false)"
v-model="hideSearch"
>
{{ $t('search.hide') }}
</b-form-checkbox>
<b-form-checkbox
v-model="topbarSettings.hideProfile"
>
{{ $t('profile.hide') }}
</b-form-checkbox>
</b-form-group>
<div>
<hr>
<b-row>
<b-col
cols="12"
lg="3"
>
<h5>{{ $t('help.title') }}</h5>
<b-form-group>
<b-form-checkbox
v-model="topbarSettings.hideForumLink"
>
{{ $t('help.hide-forum-link') }}
</b-form-checkbox>
<b-form-checkbox
v-model="topbarSettings.hideDocumentationLink"
>
{{ $t('help.hide-documentation-link') }}
</b-form-checkbox>
<b-form-checkbox
v-model="topbarSettings.hideFeedbackLink"
>
{{ $t('help.hide-feedback-link') }}
</b-form-checkbox>
</b-form-group>
</b-col>
<b-col>
<b-form-group
:label="$t('links.title')"
label-class="text-primary"
>
<c-form-table-wrapper
:labels="{ addButton: $t('general:label.add') }"
@add-item="topbarSettings.helpLinks.push({ handle: '', url: '', newTab: true })"
>
<b-table
v-if="topbarSettings.helpLinks.length > 0"
:fields="links.fields"
:items="topbarSettings.helpLinks"
thead-tr-class="text-primary"
responsive
small
>
<template #cell(handle)="data">
<b-form-input
v-model="data.item.handle"
size="sm"
/>
</template>
<template #cell(url)="data">
<b-form-input
v-model="data.item.url"
type="url"
size="sm"
/>
</template>
<template #cell(newTab)="data">
<b-form-checkbox
v-model="data.item.newTab"
/>
</template>
<template #cell(actions)="data">
<c-input-confirm
show-icon
@confirmed="topbarSettings.helpLinks.splice(data.index, 1)"
/>
</template>
</b-table>
</c-form-table-wrapper>
</b-form-group>
</b-col>
</b-row>
</div>
<div>
<hr>
<b-row>
<b-col
cols="12"
lg="3"
>
<h5>{{ $t('profile.title') }}</h5>
<b-form-group>
<b-form-checkbox
v-model="topbarSettings.hideProfileLink"
>
{{ $t('profile.hide-profile-link') }}
</b-form-checkbox>
<b-form-checkbox
v-model="topbarSettings.hideChangePasswordLink"
>
{{ $t('profile.hide-change-password-link') }}
</b-form-checkbox>
<b-form-checkbox
v-model="topbarSettings.hideThemeSelector"
>
{{ $t('profile.hide-theme-selector') }}
</b-form-checkbox>
</b-form-group>
</b-col>
<b-col>
<b-form-group
:label="$t('links.title')"
label-class="text-primary"
>
<c-form-table-wrapper
:labels="{ addButton: $t('general:label.add') }"
@add-item="topbarSettings.profileLinks.push({ handle: '', url: '', newTab: true })"
>
<b-table
v-if="topbarSettings.profileLinks.length > 0"
:fields="links.fields"
:items="topbarSettings.profileLinks"
thead-tr-class="text-primary"
responsive
small
>
<template #cell(handle)="data">
<b-form-input
v-model="data.item.handle"
size="sm"
/>
</template>
<template #cell(url)="data">
<b-form-input
v-model="data.item.url"
type="url"
size="sm"
/>
</template>
<template #cell(newTab)="data">
<b-form-checkbox
v-model="data.item.newTab"
/>
</template>
<template #cell(actions)="data">
<c-input-confirm
show-icon
@confirmed="topbarSettings.profileLinks.splice(data.index, 1)"
/>
</template>
</b-table>
</c-form-table-wrapper>
</b-form-group>
</b-col>
</b-row>
</div>
</b-form>
<template #footer>
<c-button-submit
v-if="canManage"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="onSubmit"
/>
</template>
</b-card>
</template>
<script>
export default {
name: 'CUITopbarSettings',
i18nOptions: {
namespaces: 'ui.settings',
keyPrefix: 'editor.topbar',
},
props: {
settings: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canManage: {
type: Boolean,
required: true,
},
},
data () {
return {
topbarSettings: {},
links: {
fields: [
{
key: 'handle',
label: this.$t('links.handle'),
thStyle: { width: '30%' },
},
{
key: 'url',
label: this.$t('links.url'),
thStyle: { width: '55%' },
},
{
key: 'newTab',
label: this.$t('links.new-tab'),
thClass: 'text-center',
tdClass: 'text-center align-middle',
thStyle: { width: '10%' },
},
{
key: 'actions',
label: '',
tdClass: 'text-right align-middle',
thStyle: { width: '1%' },
},
],
},
}
},
computed: {
hideDrafts: {
get () {
return this.topbarSettings.showDrafts !== true
},
set (value) {
this.$set(this.topbarSettings, 'showDrafts', !value)
},
},
hideSearch: {
get () {
return this.topbarSettings.showSearch !== true
},
set (value) {
this.$set(this.topbarSettings, 'showSearch', !value)
},
},
},
watch: {
settings: {
immediate: true,
handler (settings) {
this.topbarSettings = settings['ui.topbar'] || {}
if (!this.topbarSettings.helpLinks) {
this.$set(this.topbarSettings, 'helpLinks', [])
}
if (!this.topbarSettings.profileLinks) {
this.$set(this.topbarSettings, 'profileLinks', [])
}
},
},
},
methods: {
onSubmit () {
this.$emit('submit', { 'ui.topbar': this.topbarSettings })
},
},
}
</script>

View File

@@ -0,0 +1,56 @@
import { default as component } from './CTemplateEditorInfo.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
template: {
templateID: '',
meta: {
short: 'Name',
description: 'Desc',
},
handle: 'Handle',
},
processing: false,
success: false,
canCreate: true,
}
export default {
name: 'Editor info',
group: ['Templates'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
checkbox('CanCreate', 'canCreate'),
checkbox('Enable delete', {
value (p) { return p.template.templateID.length > 0 },
update (p, val) { p.template.templateID = val ? '123456789' : '' },
}),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
template: {
...props.template,
meta: {
short: '',
description: '',
},
handle: '',
},
canCreate: false,
},
},
],
}

View File

@@ -0,0 +1,34 @@
<template>
<c-ace-editor
v-model="template.template"
data-test-id="template-html-editor"
name="editor/html"
lang="html"
min-height="400px"
show-line-numbers
highlight-active-line
show-print-margin
:border="false"
resizable
/>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CAceEditor } = components
export default {
components: {
CAceEditor,
},
props: {
template: {
type: Object,
required: true,
default: () => ({}),
},
},
}
</script>

View File

@@ -0,0 +1,31 @@
<template>
<c-ace-editor
v-model="template.template"
data-test-id="template-plain-text-editor"
name="editor/plain_text"
lang="plain_text"
min-height="400px"
show-line-numbers
resizable
/>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CAceEditor } = components
export default {
components: {
CAceEditor,
},
props: {
template: {
type: Object,
required: true,
default: () => ({}),
},
},
}
</script>

View File

@@ -0,0 +1,165 @@
<template>
<b-card
data-test-id="card-template-toolbox"
no-body
header-class="border-bottom"
class="shadow-sm h-100"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-card-body>
<span
v-for="sec in sections"
:key="sec.key"
>
<b-btn
:data-test-id="toolboxSectionLabelCypressId(sec.key)"
variant="light"
class="mb-2"
block
@click="openSection(sec.key)"
>
{{ $t(sec.key) }}
</b-btn>
<b-collapse
:visible="expandedSections[sec.key]"
role="tabpanel"
class="pb-2 px-0"
>
<b-list-group flush>
<b-list-group-item
v-for="(opt, i) in sec.options"
:key="opt.label + i"
:data-test-id="toolboxOptionLabelCypressId(opt.label)"
class="px-0 text-wrap"
@click="opt.onClick || (() => {})"
>
{{ opt.label }}
<b-btn
data-test-id="button-copy"
variant="link"
class="pr-0 float-right"
@click="copyToCb(opt.copyValue())"
>
<font-awesome-icon
v-if="opt.copyValue"
:icon="['far', 'copy']"
/>
</b-btn>
</b-list-group-item>
</b-list-group>
</b-collapse>
</span>
</b-card-body>
</b-card>
</template>
<script>
import copy from 'copy-to-clipboard'
export default {
i18nOptions: {
namespaces: 'system.templates',
keyPrefix: 'editor.content.toolbox',
},
props: {
template: {
type: Object,
required: true,
default: () => ({}),
},
partials: {
type: Array,
required: false,
default: () => [],
},
},
data () {
return {
expandedSections: {},
}
},
computed: {
sections () {
const partials = this.partials.map(p => ({
label: p.meta.short || p.handle,
copyValue: () => `{{template "${p.handle}" }}`,
}))
const rr = []
if (partials.length) {
rr.push({
key: 'partials',
options: partials,
})
}
rr.push({
key: 'snippets.label',
options: [
{
label: this.$t('snippets.interpolate'),
copyValue: () => '{{.parameter}}',
},
{
label: this.$t('snippets.iterator'),
copyValue: () => '{{range $index, $element := .ListOfItems}}\n\n{{end}}',
},
{
label: this.$t('snippets.funcCall'),
copyValue: () => '{{funcName param1 param2 paramN}}',
},
],
},
{
key: 'samples.label',
options: [
{
label: this.$t('samples.defaultHTML'),
copyValue: () => `<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<title>Title</title>
<meta name='viewport' content='width=device-width, initial-scale=1'>
</head>
<body>
<h1>Hello, world!</h1>
</body>
</html>`,
},
],
})
return rr
},
},
methods: {
openSection (sec) {
this.$set(this.expandedSections, sec, !this.expandedSections[sec])
},
copyToCb: copy,
toolboxSectionLabelCypressId (section) {
return `button-${this.$t(section).toLowerCase()}`
},
toolboxOptionLabelCypressId (label) {
return label.toLowerCase().split(' ').join('-')
},
},
}
</script>

View File

@@ -0,0 +1,14 @@
<template>
<p>
{{ $t('editor.unsupported') }}
</p>
</template>
<script>
export default {
i18nOptions: {
namespaces: 'system.templates',
keyPrefix: 'editor.content.editor',
},
}
</script>

View File

@@ -0,0 +1,303 @@
<template>
<b-row>
<!-- Toolbox -->
<b-col
cols="12"
lg="3"
class="mb-3"
>
<editor-toolbox
:template="template"
:partials="partials"
/>
</b-col>
<!-- Preview configuration -->
<b-col
cols="12"
lg="9"
class="mb-3"
>
<b-card
v-if="!template.partial && !fresh"
body-class="p-0"
header-class="border-bottom"
footer-class="border-top d-flex justify-content-end flex-wrap flex-fill-child gap-1"
>
<!-- Partial templates can't be previewed -->
<c-ace-editor
v-model="previewData"
data-test-id="template-preview-output"
name="preview-data"
lang="json"
min-height="300px"
show-line-numbers
highlight-active-line
resizable
:border="false"
/>
<template #header>
<h4 class="m-0">
{{ $t('preview.title') }}
</h4>
</template>
<template #footer>
<b-btn
v-if="canPreviewHTML"
data-test-id="button-preview-html-template"
variant="light"
@click="openPreview('html')"
>
{{ $t('preview.html') }}
</b-btn>
<b-btn
v-if="canPreviewPDF"
data-test-id="button-preview-pdf-template"
variant="light"
@click="openPreview('pdf')"
>
{{ $t('preview.pdf') }}
</b-btn>
</template>
</b-card>
</b-col>
<!-- Content editor -->
<b-col cols="12">
<b-card
body-class="p-0"
header-class="d-flex align-items-center border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<b-row
v-if="!template.partial"
class="p-3 border-bottom no-gutters"
>
<b-col
cols="12"
lg="6"
class="pr-lg-2"
>
<b-form-group
:label="$t('headerTemplate')"
:description="$t('headerTemplateDescription')"
label-class="text-primary"
class="mb-0"
>
<b-form-select
v-model="headerTemplateID"
data-test-id="select-header-template"
:options="partialOptions"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
class="pl-lg-2"
>
<b-form-group
:label="$t('footerTemplate')"
:description="$t('footerTemplateDescription')"
label-class="text-primary"
class="mb-0"
>
<b-form-select
v-model="footerTemplateID"
data-test-id="select-footer-template"
:options="partialOptions"
/>
</b-form-group>
</b-col>
</b-row>
<component
:is="editor"
:template="template"
/>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
<b-badge
v-if="template.partial"
data-test-id="badge-partial-template"
variant="primary"
class="ml-2"
>
{{ $t('partial') }}
</b-badge>
</template>
<template #footer>
<c-button-submit
:disabled="!canCreate"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', template)"
/>
</template>
</b-card>
</b-col>
</b-row>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
import listHelpers from 'corteza-webapp-admin/src/mixins/listHelpers'
import EditorToolbox from './EditorToolbox'
import EditorTextHtml from './EditorTextHtml'
import EditorTextPlain from './EditorTextPlain'
import EditorUnsupported from './EditorUnsupported'
import { components } from '@cortezaproject/corteza-vue'
const { CAceEditor } = components
export default {
components: {
CAceEditor,
EditorToolbox,
},
mixins: [
listHelpers,
],
i18nOptions: {
namespaces: 'system.templates',
keyPrefix: 'editor.content',
},
props: {
template: {
type: Object,
required: true,
default: () => ({}),
},
partials: {
type: Array,
required: false,
default: () => [],
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
data () {
return {
// @todo i18n this?
previewData: '{\n "variables": {\n "param1": "value1",\n "param2": {\n "nestedParam1": "value2"\n }\n },\n "options": {\n "documentSize": "A4",\n "contentScale": "1",\n "orientation": "portrait",\n "margin": "0.3"\n }\n}\n',
previewBlob: '',
availableDrivers: [],
}
},
computed: {
fresh () {
return !this.template.templateID || this.template.templateID === NoID
},
partialOptions () {
const options = this.partials
.filter(({ templateID }) => templateID !== this.template.templateID)
.map(({ templateID, meta = {}, handle: h }) => ({ value: templateID, text: meta.short || h || templateID }))
return [{ value: NoID, text: this.$t('general:label.none') }, ...options]
},
headerTemplateID: {
get () {
return this.template.meta.headerTemplateID || NoID
},
set (headerTemplateID) {
this.$set(this.template.meta, 'headerTemplateID', headerTemplateID)
},
},
footerTemplateID: {
get () {
return this.template.meta.footerTemplateID || NoID
},
set (footerTemplateID) {
this.$set(this.template.meta, 'footerTemplateID', footerTemplateID)
},
},
editor () {
switch (this.template.type) {
case 'text/html':
return EditorTextHtml
case 'text/plain':
return EditorTextPlain
default:
return EditorUnsupported
}
},
canPreviewHTML () {
return this.availableDrivers.find(({ outputTypes }) => outputTypes.includes('text/html'))
},
canPreviewPDF () {
return this.availableDrivers.find(({ outputTypes }) => outputTypes.includes('application/pdf'))
},
},
async created () {
this.availableDrivers = await this.$SystemAPI.templateRenderDrivers()
.then(rsp => rsp.set)
.catch(this.toastErrorHandler(this.$t('notification:template.driver.error')))
},
methods: {
openPreview (ext) {
this.incLoader()
const cfg = {
method: 'post',
responseType: 'blob',
url: this.$SystemAPI.templateRenderEndpoint({
templateID: this.template.templateID,
filename: 'preview',
ext,
}),
data: JSON.parse(this.previewData),
}
this.$SystemAPI.api().request(cfg)
.then(r => {
this.previewBlob = window.URL.createObjectURL(r.data)
window.open(this.previewBlob, '_newtab')
})
.catch(this.toastErrorHandler(this.$t('notification:template.preview.error')))
.finally(() => {
this.decLoader()
})
},
},
}
</script>

View File

@@ -0,0 +1,226 @@
<template>
<b-card
data-test-id="card-template-info"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
@submit="$emit('submit', template)"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('meta.short')"
label-class="text-primary"
>
<b-form-input
v-model="template.meta.short"
data-test-id="input-short-name"
required
:state="shortState"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('handle')"
label-class="text-primary"
:class="{ 'mb-0': !template.templateID }"
>
<b-form-input
v-model="template.handle"
data-test-id="input-handle"
:state="handleState"
:placeholder="$t('placeholder-handle')"
/>
<b-form-invalid-feedback :state="handleState">
{{ $t('invalid-handle-characters') }}
</b-form-invalid-feedback>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('type')"
label-class="text-primary"
>
<b-form-select
v-model="template.type"
data-test-id="select-template-type"
:options="contentTypes"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('meta.description')"
label-class="text-primary"
>
<b-form-textarea
v-model="template.meta.description"
data-test-id="textarea-description"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('partial')"
:description="$t('partialDescription')"
label-class="text-primary"
>
<c-input-checkbox
v-model="template.partial"
data-test-id="checkbox-is-partial-template"
switch
:labels="checkboxLabel"
name="checkbox-1"
/>
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:resource="template"
/>
<!--
include hidden input to enable
trigger submit event w/ ENTER
-->
<input
type="submit"
class="d-none"
:disabled="saveDisabled"
>
</b-form>
<template #footer>
<c-input-confirm
v-if="!fresh && template.canDeleteTemplate"
:data-test-id="getDeletedButtonStatusCypressId"
:text="getDeleteStatus"
variant="danger"
size="md"
@confirmed="$emit('delete')"
/>
<c-button-submit
:disabled="saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', template)"
/>
</template>
</b-card>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
import { handle } from '@cortezaproject/corteza-vue'
export default {
name: 'CTemplateEditorInfo',
i18nOptions: {
namespaces: 'system.templates',
keyPrefix: 'editor.info',
},
props: {
template: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
data () {
return {
contentTypes: [
{ value: 'text/html', text: this.$t('contentType.text_html') },
{ value: 'text/plain', text: this.$t('contentType.text_plain') },
],
checkboxLabel: {
on: this.$t('general:label.general.yes'),
off: this.$t('general:label.general.no'),
},
}
},
computed: {
fresh () {
return !this.template.templateID || this.template.templateID === NoID
},
editable () {
return this.fresh ? this.canCreate : true // this.template.canUpdateRole
},
shortState () {
return this.template.meta.short ? null : false
},
handleState () {
return handle.handleState(this.template.handle)
},
saveDisabled () {
return !this.editable || [this.shortState, this.handleState].includes(false)
},
getDeleteStatus () {
return this.template.deletedAt ? this.$t('undelete') : this.$t('delete')
},
getArchiveStatus () {
return this.template.archivedAt ? this.$t('unarchive') : this.$t('archive')
},
getDeletedButtonStatusCypressId () {
return `button-${this.getDeleteStatus.toLowerCase()}`
},
},
}
</script>

View File

@@ -0,0 +1,52 @@
import { default as component } from './CUserEditorInfo.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
user: {
userID: '',
email: 'email@mail.bg',
name: 'Stefan',
handle: 'SS',
},
uiPage: 'user/editor',
resourceType: 'system:user',
uiSlot: 'infoFooter',
processing: false,
success: false,
}
export default {
name: 'Editor info',
group: ['User'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
checkbox('Enable delete and suspend', {
value (p) { return p.user.userID.length > 0 },
update (p, val) { p.user.userID = val ? '123456789' : '' },
}),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
user: {
...props.user,
email: '',
name: '',
handle: '',
},
},
},
],
}

View File

@@ -0,0 +1,170 @@
<template>
<b-card
data-test-id="card-user-profile-avatar"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
enctype="multipart/form-data"
@submit.prevent="$emit('submit', user)"
>
<img
:src="uploadedAvatar('avatar')"
style="height: 4rem; width: 4rem;"
class="rounded-circle mb-4"
>
<div
class="d-flex align-items-center"
>
<c-uploader-with-preview
:endpoint="$SystemAPI.userProfileAvatarEndpoint({ userID: user.userID })"
@upload="$emit('onUpload')"
@clear="$emit('resetAttachment', 'avatar')"
/>
<c-input-confirm
v-if="uploadedAvatar('avatar') && isKindAvatar"
:processing="processingAvatar"
:text="$t('general:label.delete')"
size="lg"
size-confirm="lg"
variant="danger"
class="ml-2 h-100"
@confirmed="$emit('resetAttachment', 'avatar')"
/>
</div>
<b-row class="mt-3">
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('initial.color')"
label-class="text-primary"
>
<c-input-color-picker
v-model="user.meta.avatarColor"
data-test-id="input-text-color"
:translations="{
modalTitle: $t('colorPicker'),
light: $t('ui.settings:editor.corteza-studio.tabs.light'),
dark: $t('ui.settings:editor.corteza-studio.tabs.dark'),
cancelBtnLabel: $t('general:label.cancel'),
saveBtnLabel: $t('general:label.saveAndClose')
}"
:theme-settings="themeSettings"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('initial.backgroundColor')"
label-class="text-primary"
>
<c-input-color-picker
v-model="user.meta.avatarBgColor"
data-test-id="input-background-color"
:translations="{
modalTitle: $t('colorPicker'),
light: $t('ui.settings:editor.corteza-studio.tabs.light'),
dark: $t('ui.settings:editor.corteza-studio.tabs.dark'),
cancelBtnLabel: $t('general:label.cancel'),
saveBtnLabel: $t('general:label.saveAndClose')
}"
:theme-settings="themeSettings"
/>
</b-form-group>
</b-col>
</b-row>
</b-form>
<template #footer>
<c-button-submit
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', user)"
/>
</template>
</b-card>
</template>
<script>
import CUploaderWithPreview from 'corteza-webapp-admin/src/components/CUploaderWithPreview'
import { components } from '@cortezaproject/corteza-vue'
const { CInputColorPicker } = components
export default {
name: 'CUserEditorAvatar',
i18nOptions: {
namespaces: 'system.users',
keyPrefix: 'editor.avatar',
},
components: {
CUploaderWithPreview,
CInputColorPicker,
},
props: {
user: {
type: Object,
required: true,
},
processing: {
type: Boolean,
},
processingAvatar: {
type: Boolean,
},
success: {
type: Boolean,
},
},
computed: {
isKindAvatar () {
return this.user.meta.avatarKind === 'avatar'
},
themeSettings () {
return this.$Settings.get('ui.studio.themes', [])
},
},
methods: {
uploadedAvatar (name) {
const attachmentID = this.user.meta.avatarID
if (attachmentID !== '0') {
return (
this.$SystemAPI.baseURL +
this.$SystemAPI.attachmentOriginalEndpoint({
attachmentID,
kind: 'avatar',
name,
})
)
}
},
},
}
</script>

View File

@@ -0,0 +1,61 @@
<template>
<b-card
v-if="value && value.length > 0"
data-test-id="card-external-auth-providers"
header-class="border-bottom"
body-class="p-0"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-table
:items="value"
:fields="fields"
head-variant="light"
responsive
hover
class="mb-0"
style="min-height: 200px;"
>
<template #cell(editor)="{ item }">
<c-input-confirm
data-test-id="button-remove-provider"
show-icon
@confirmed="$emit('delete', item.credentialsID)"
/>
</template>
</b-table>
</b-card>
</template>
<script>
export default {
name: 'CUserEditorExternalAuthProviders',
i18nOptions: {
namespaces: 'system.users',
keyPrefix: 'editor.external-auth-providers',
},
props: {
value: {
type: Array,
required: true,
},
},
computed: {
fields () {
return [
{ key: 'label', label: this.$t('label'), thStyle: { width: '350px' } },
{ key: 'type', label: this.$t('type'), thStyle: { width: '250px' } },
{ key: 'editor', label: '', tdClass: 'text-right' },
]
},
},
}
</script>

View File

@@ -0,0 +1,248 @@
<template>
<b-card
data-test-id="card-user-info"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<b-form
@submit.prevent="$emit('submit', user)"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('email')"
label-class="text-primary"
>
<b-form-input
v-model="user.email"
data-test-id="input-email"
required
:state="emailState"
type="email"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('name')"
label-class="text-primary"
>
<b-form-input
v-model="user.name"
data-test-id="input-name"
required
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('handle')"
:class="{ 'mb-0': !user.userID }"
label-class="text-primary"
>
<b-form-input
v-model="user.handle"
data-test-id="input-handle"
:placeholder="$t('placeholder-handle')"
:state="handleState"
/>
<b-form-invalid-feedback :state="handleState">
{{ $t('invalid-handle-characters') }}
</b-form-invalid-feedback>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('userGroup.label')"
:class="{ 'mb-0': !user.userID }"
label-class="text-primary"
>
<c-input-user-group
v-model="user.userGroupID"
:placeholder="$t('userGroup.placeholder')"
/>
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:id="user.userID"
:resource="user"
/>
<!--
include hidden input to enable
trigger submit event w/ ENTER
-->
<input
type="submit"
class="d-none"
:disabled="saveDisabled"
>
</b-form>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<template #footer>
<c-input-confirm
v-if="!fresh && user.canDeleteUser"
:data-test-id="deletedButtonStatusCypressId"
:text="getDeleteStatus"
variant="danger"
size="md"
@confirmed="$emit('delete')"
/>
<c-input-confirm
v-if="!fresh"
:data-test-id="suspendButtonStatusCypressId"
:text="getSuspendStatus"
variant="light"
size="md"
@confirmed="$emit('status')"
/>
<c-input-confirm
v-if="!fresh"
data-test-id="button-sessions-revoke"
:text="$t('revokeAllSession')"
:disabled="user.userID === userID"
variant="light"
size="md"
@confirmed="$emit('sessionsRevoke')"
/>
<b-button
v-if="!fresh && !user.emailConfirmed"
variant="light"
@click="$emit('patch', '/emailConfirmed', true)"
>
{{ $t('confirmEmail') }}
</b-button>
<c-corredor-manual-buttons
ui-page="user/editor"
ui-slot="infoFooter"
resource-type="system:user"
default-variant="light"
@click="dispatchCortezaSystemUserEvent($event, { user })"
/>
<c-button-submit
:disabled="saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', user)"
/>
</template>
</b-card>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
import { handle, components } from '@cortezaproject/corteza-vue'
const { CInputUserGroup } = components
export default {
name: 'CUserEditorInfo',
i18nOptions: {
namespaces: 'system.users',
keyPrefix: 'editor.info',
},
components: {
CInputUserGroup,
},
props: {
user: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
computed: {
getDeleteStatus () {
return this.user.deletedAt ? this.$t('undelete') : this.$t('delete')
},
getSuspendStatus () {
return this.user.suspendedAt ? this.$t('unsuspend') : this.$t('suspend')
},
userID () {
if (this.$auth.user) {
return this.$auth.user.userID
}
return undefined
},
fresh () {
return !this.user.userID || this.user.userID === NoID
},
editable () {
return this.fresh ? this.canCreate : this.user.canUpdateUser
},
emailState () {
const { email } = this.user
return email ? null : false
},
handleState () {
return handle.handleState(this.user.handle)
},
saveDisabled () {
return !this.editable || [this.emailState, this.handleState].includes(false)
},
deletedButtonStatusCypressId () {
return `button-${this.getDeleteStatus.toLowerCase()}`
},
suspendButtonStatusCypressId () {
return `button-${this.getSuspendStatus.toLowerCase()}`
},
},
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<b-card
header-class="border-bottom"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<div class="d-flex align-items-center flex-wrap">
<div>
<span
v-if="mfa.enforcedEmailOTP"
v-html="$t('emailOTP.enabled.text')"
/>
<span
v-else
v-html="$t('emailOTP.disabled.text')"
/>
</div>
<div class="ml-auto">
<b-button
v-if="mfa.enforcedEmailOTP"
@click="$emit('patch', '/meta/securityPolicy/mfa/enforcedEmailOTP', false)"
>
{{ $t('emailOTP.disable.label') }}
</b-button>
<b-button
v-else
@click="$emit('patch', '/meta/securityPolicy/mfa/enforcedEmailOTP', true)"
>
{{ $t('emailOTP.enable.label') }}
</b-button>
</div>
</div>
<div class="d-flex align-items-center justify-content-between flex-wrap mt-2 pt-2 border-top">
<div>
<span
v-if="mfa.enforcedTOTP"
v-html="$t('TOTP.enabled.text')"
/>
<span
v-else
v-html="$t('TOTP.disabled.text')"
/>
</div>
<div class="ml-auto">
<b-button
:disabled="!mfa.enforcedTOTP"
@click="$emit('patch', '/meta/securityPolicy/mfa/enforcedTOTP', false)"
>
{{ $t('TOTP.remove.label') }}
</b-button>
</div>
</div>
</b-card>
</template>
<script>
/**
* @todo find a way to get this number from the backend
* @type {number}
*/
// const minPasswordLength = 5
export default {
name: 'CUserEditorMFA',
i18nOptions: {
namespaces: 'system.users',
keyPrefix: 'editor.mfa',
},
props: {
mfa: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
},
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,168 @@
<template>
<b-card
data-test-id="card-user-password"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4
data-test-id="card-title"
class="m-0"
>
{{ $t('title') }}
</h4>
</template>
<b-form
@submit.prevent="onPasswordSubmit"
>
<b-row>
<b-col cols="12">
<b-form-group
:label="$t('new')"
:description="getPasswordWarning"
label-class="text-primary"
>
<b-form-input
v-model="password"
data-test-id="input-new-password"
:state="passwordState"
autocomplete="new-password"
required
type="password"
/>
</b-form-group>
</b-col>
<b-col cols="12">
<b-form-group
:label="$t('confirm')"
:description="getConfirmPasswordWarning"
label-class="text-primary"
class="mb-0"
>
<b-form-input
v-model="confirmPassword"
data-test-id="input-confirm-password"
type="password"
autocomplete="new-password"
required
:disabled="!passwordState"
:state="confirmPasswordState"
/>
</b-form-group>
</b-col>
</b-row>
</b-form>
<template #footer>
<c-input-confirm
data-test-id="button-remove-password"
:text="$t('removePassword')"
variant="light"
size="md"
@confirmed="$emit('submit')"
/>
<c-corredor-manual-buttons
ui-page="user/editor"
ui-slot="passwordFooter"
resource-type="system:user"
default-variant="light"
@click="dispatchCortezaSystemEvent($event)"
/>
<c-button-submit
:disabled="!passwordState || !confirmPasswordState"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="onPasswordSubmit"
/>
</template>
</b-card>
</template>
<script>
export default {
name: 'CUserEditorPassword',
i18nOptions: {
namespaces: 'system.users',
keyPrefix: 'editor.password',
},
props: {
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
userID: {
type: String,
required: false,
default: undefined,
},
},
data () {
return {
password: '',
confirmPassword: '',
minPasswordLength: this.$Settings.get('auth.internal.passwordConstraints.minLength', 8),
}
},
computed: {
passwordState () {
if (this.password.length > 0) {
return this.password.length >= this.minPasswordLength
}
return null
},
confirmPasswordState () {
if (this.passwordState && this.confirmPassword.length > 0) {
return this.password === this.confirmPassword
}
return null
},
getPasswordWarning () {
if (this.passwordState === false) {
return this.$t('length', { length: this.minPasswordLength })
}
return null
},
getConfirmPasswordWarning () {
if (this.confirmPasswordState === false) {
return this.$t('missmatch')
}
return null
},
},
methods: {
onPasswordSubmit () {
this.$emit('submit', this.password)
this.password = ''
this.confirmPassword = ''
},
},
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,66 @@
<template>
<b-card
data-test-id="card-role-membership"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<b-form
@submit.prevent="$emit('submit')"
>
<c-role-picker
:value="value"
@input="$emit('input', $event)"
/>
</b-form>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<template #footer>
<c-button-submit
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit')"
/>
</template>
</b-card>
</template>
<script>
import CRolePicker from 'corteza-webapp-admin/src/components/CRolePicker'
export default {
i18nOptions: {
namespaces: 'system.users',
keyPrefix: 'editor.roles',
},
components: {
CRolePicker,
},
props: {
value: {
type: Array,
required: true,
default: () => [],
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
},
}
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div>
<b-form-group>
<b-form-checkbox
v-model="inclRoleMembership"
data-test-id="checkbox-include-role-membership"
:value="true"
:unchecked-value="false"
>
{{ $t('export.inclRoleMembership') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
:description="!inclRoleMembership
? $t('export.membershipRequiredLabel')
: ''
"
class="mb-0"
>
<b-form-checkbox
v-model="inclRoles"
data-test-id="checkbox-include-roles"
:value="true"
:unchecked-value="false"
:disabled="!inclRoleMembership"
>
{{ $t('export.inclRoles') }}
</b-form-checkbox>
</b-form-group>
<div slot="footer">
<b-button
data-test-id="button-export"
variant="primary"
class="float-right"
@click="nextStep"
>
{{ $t('export.export') }}
</b-button>
</div>
</div>
</template>
<script>
export default {
i18nOptions: {
namespaces: 'system.users',
},
name: 'CUserExportConfiguration',
data () {
return {
inclRoleMembership: false,
inclRoles: false,
}
},
methods: {
nextStep () {
const rtr = {
inclRoleMembership: this.inclRoleMembership,
inclRoles: this.inclRoles,
}
this.$emit('configured', rtr)
},
},
}
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="d-flex">
<b-button
data-test-id="button-export"
variant="light"
size="lg"
class="flex-fill"
@click="showModal=true"
>
{{ $t('export.buttonLabel') }}
</b-button>
<b-modal
:visible="showModal"
size="lg"
:title="$t('export.title')"
hide-footer
centered
@hide="onModalHide"
>
<keep-alive>
<component
:is="stepComponent"
v-if="!processing"
v-bind="$props"
:session="session"
@configured="onConfigured"
@close="onClose"
v-on="$listeners"
/>
<div
v-else
class="p-5 h-100 d-flex align-items-center justify-content-center"
>
<b-spinner />
</div>
</keep-alive>
</b-modal>
</div>
</template>
<script>
import ExportConfiguration from './ExportConfiguration.vue'
export default {
i18nOptions: {
namespaces: 'system.users',
},
name: 'CUserExport',
data () {
return {
step: 0,
showModal: false,
session: {},
components: [ExportConfiguration],
processing: false,
}
},
computed: {
stepComponent () {
return this.components[this.step]
},
},
methods: {
onModalHide () {
this.step = 0
this.session = {}
this.showModal = false
},
async onConfigured (e) {
this.processing = true
this.$emit('export', e)
this.onReset()
this.onClose()
this.processing = false
},
onReset () {
this.step = 0
this.$emit('reset')
},
onClose () {
this.showModal = false
},
},
}
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div>
<c-uploader
:labels="{
placeholder: $t('general:label.dropFiles'),
uploading: $t('general:label.uploading'),
fileTypeNotAllowed: $t('general:label.fileTypeNotAllowed'),
}"
:endpoint="userImportEndpoint"
:accepted-files="['application/zip']"
@upload="onUploaded"
/>
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CUploader } = components
export default {
i18nOptions: {
namespaces: 'system.users',
},
name: 'CFileUpload',
components: {
CUploader,
},
data () {
return {
session: null,
sessionFile: null,
}
},
computed: {
userImportEndpoint () {
return this.$SystemAPI.baseURL + this.$SystemAPI.userImportEndpoint({})
},
},
methods: {
onUploaded () {
this.$emit('imported', {})
},
},
}
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div class="d-flex">
<b-button
data-test-id="button-import"
variant="light"
size="lg"
class="flex-fill"
@click="showModal=true"
>
{{ $t('import.buttonLabel') }}
</b-button>
<b-modal
:visible="showModal"
size="lg"
:title="$t('import.title')"
centered
hide-footer
@hide="onModalHide"
>
<keep-alive>
<component
:is="stepComponent"
v-bind="$props"
@imported="onImported"
@reset="onReset"
@close="onClose"
/>
</keep-alive>
</b-modal>
</div>
</template>
<script>
import FileUpload from './FileUpload'
export default {
i18nOptions: {
namespaces: 'system.users',
},
name: 'CUserImport',
data () {
return {
step: 0,
showModal: false,
components: [FileUpload],
}
},
computed: {
stepComponent () {
return this.components[this.step]
},
},
methods: {
onModalHide () {
this.step = 0
this.showModal = false
},
onImported (e) {
this.$emit('imported')
this.onReset()
this.onClose()
},
onReset () {
this.step = 0
this.$emit('reset')
},
onClose () {
this.showModal = false
},
},
}
</script>

View File

@@ -0,0 +1,314 @@
<template>
<b-card
data-test-id="card-user-group-info"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<b-form
@submit.prevent="$emit('submit', userGroup)"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('meta.short')"
label-class="text-primary"
>
<b-form-input
v-model="userGroup.meta.short"
data-test-id="input-name"
required
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('handle')"
:class="{ 'mb-0': !userGroup.userGroupID }"
label-class="text-primary"
>
<b-form-input
v-model="userGroup.handle"
data-test-id="input-handle"
:placeholder="$t('placeholder-handle')"
:state="handleState"
/>
<b-form-invalid-feedback :state="handleState">
{{ $t('invalid-handle-characters') }}
</b-form-invalid-feedback>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
>
<b-form-group
:label="$t('meta.description')"
label-class="text-primary"
>
<b-form-textarea
v-model="userGroup.meta.description"
data-test-id="textarea-description"
/>
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:id="userGroup.userGroupID"
:resource="userGroup"
/>
<template v-if="!isRoot">
<hr>
<div v-if="!isRoot">
<h5 class="mb-3">
{{ $t('parents.title') }}
</h5>
<c-form-table-wrapper
:labels="{
addButton: $t('general:label.add')
}"
class="my-3"
@add-item="addParent"
>
<b-table-simple
v-if="userGroup.config.path"
borderless
small
responsive
>
<b-thead>
<b-tr>
<b-th class="text-primary">
{{ $t('parents.parent.label') }}
</b-th>
<b-th class="text-primary">
{{ $t('parents.name.label') }}
</b-th>
<b-th v-if="userGroup.config.path.length > 1" />
</b-tr>
</b-thead>
<b-tbody>
<b-tr
v-for="(parent, i) in userGroup.config.path"
:key="i"
>
<b-td
class="align-middle"
style="min-width: 250px;"
>
<c-input-user-group
v-model="parent.selfID"
:placeholder="$t('parents.parent.placeholder')"
/>
</b-td>
<b-td
class="align-middle"
style="min-width: 200px;"
>
<b-form-input
v-model="parent.name"
:placeholder="$t('parents.name.placeholder')"
/>
</b-td>
<b-td
v-if="userGroup.config.path.length > 1"
class="text-right align-middle"
style="width: 1%"
>
<c-input-confirm
show-icon
@confirmed="deleteParent(i)"
/>
</b-td>
</b-tr>
</b-tbody>
</b-table-simple>
</c-form-table-wrapper>
</div>
</template>
<!--
include hidden input to enable
trigger submit event w/ ENTER
-->
<input
type="submit"
class="d-none"
:disabled="saveDisabled"
>
</b-form>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<template #footer>
<c-input-confirm
v-if="!fresh && userGroup.canDeleteUserGroup"
:data-test-id="deletedButtonStatusCypressId"
:text="getDeleteStatus"
variant="danger"
size="md"
@confirmed="$emit('delete')"
/>
<c-corredor-manual-buttons
ui-page="user-group/editor"
ui-slot="infoFooter"
resource-type="system:user-group"
default-variant="light"
@click="dispatchCortezaSystemUserGroupEvent($event, { userGroup })"
/>
<c-button-submit
:disabled="saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', userGroup)"
/>
</template>
</b-card>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
import { handle, components } from '@cortezaproject/corteza-vue'
const { CInputUserGroup } = components
export default {
name: 'CUserGroupEditorInfo',
i18nOptions: {
namespaces: 'system.user-groups',
keyPrefix: 'editor.info',
},
components: {
CInputUserGroup,
},
props: {
userGroup: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
computed: {
isRoot () {
return this.userGroup.isRoot
},
getDeleteStatus () {
return this.userGroup.deletedAt ? this.$t('undelete') : this.$t('delete')
},
userGroupID () {
if (this.userGroup) {
return this.userGroup.userGroupID
}
return undefined
},
fresh () {
return !this.userGroupID || this.userGroupID === NoID
},
editable () {
return this.fresh ? this.canCreate : this.userGroup.canUpdateUserGroup
},
nameState () {
return this.userGroup.meta.short ? null : false
},
handleState () {
return handle.handleState(this.userGroup.handle)
},
saveDisabled () {
return !this.editable || [this.nameState, this.handleState, this.parentState].includes(false)
},
parentState () {
if (this.isRoot) {
return null
}
if (!this.userGroup.config.path || this.userGroup.config.path.length === 0) {
return false
}
return this.userGroup.config.path.every(parent => parent.selfID) ? null : false
},
deletedButtonStatusCypressId () {
return `button-${this.getDeleteStatus.toLowerCase()}`
},
suspendButtonStatusCypressId () {
return `button-${this.getSuspendStatus.toLowerCase()}`
},
},
watch: {
userGroupID: {
immediate: true,
handler () {
const { config = {} } = this.userGroup || {}
const { path = [] } = config || {}
if (!this.isRoot && path.length === 0) {
this.addParent()
}
},
},
},
methods: {
addParent () {
this.userGroup.config.path.push({
selfID: '',
name: '',
})
},
deleteParent (i) {
this.userGroup.config.path.splice(i, 1)
},
},
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<b-card
data-test-id="card-user-group-edit-members"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<b-form @submit.prevent="$emit('submit')">
<c-member-picker
:value="value"
no-remove
@input="$emit('input', $event)"
/>
</b-form>
<template #header>
<h4 class="m-0">
{{ $t("title") }}
</h4>
</template>
<template #footer>
<c-button-submit
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit')"
/>
</template>
</b-card>
</template>
<script>
import CMemberPicker from 'corteza-webapp-admin/src/components/CMemberPicker'
export default {
i18nOptions: {
namespaces: 'system.user-groups',
keyPrefix: 'editor.members',
},
components: {
CMemberPicker,
},
props: {
value: {
type: Array,
required: true,
default: () => [],
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
},
}
</script>

View File

@@ -0,0 +1,66 @@
<template>
<b-card
data-test-id="card-user-group-edit-roles"
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<b-form
@submit.prevent="$emit('submit')"
>
<c-role-picker
:value="value"
@input="$emit('input', $event)"
/>
</b-form>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<template #footer>
<c-button-submit
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit')"
/>
</template>
</b-card>
</template>
<script>
import CRolePicker from 'corteza-webapp-admin/src/components/CRolePicker'
export default {
i18nOptions: {
namespaces: 'system.user-groups',
keyPrefix: 'editor.roles',
},
components: {
CRolePicker,
},
props: {
value: {
type: Array,
required: true,
default: () => [],
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
},
}
</script>

View File

@@ -0,0 +1,56 @@
import { default as component } from './CWorkflowEditorInfo.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
workflow: {
workflowID: '',
meta: {
name: 'CRM-Test',
},
handle: 'CRMT',
enabled: true,
},
processing: false,
success: false,
canCreate: true,
}
export default {
name: 'Editor info',
group: ['Workflow'],
component,
props,
controls: [
checkbox('Processing', 'processing'),
checkbox('Success', 'success'),
checkbox('CanCreate', 'canCreate'),
checkbox('Enable delete', {
value (p) { return p.workflow.workflowID.length > 0 },
update (p, val) { p.workflow.workflowID = val ? '123456789' : '' },
}),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
workflow: {
...props.workflow,
meta: {
name: '',
},
handle: '',
enabled: false,
},
canCreate: false,
},
},
],
}

View File

@@ -0,0 +1,192 @@
<template>
<b-card
header-class="border-bottom"
footer-class="border-top d-flex flex-wrap flex-fill-child gap-1"
class="shadow-sm"
>
<template #header>
<h4 class="m-0">
{{ $t('title') }}
</h4>
</template>
<b-form
@submit.prevent="$emit('submit', workflow)"
>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
v-if="workflow.meta"
:label="$t('name')"
label-class="text-primary"
>
<b-form-input
v-model="workflow.meta.name"
required
:state="nameState"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('handle')"
label-class="text-primary"
>
<b-form-input
v-model="workflow.handle"
:state="handleState"
/>
<b-form-invalid-feedback :state="handleState">
{{ $t('invalid-handle-characters') }}
</b-form-invalid-feedback>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('enabled')"
label-class="text-primary"
:class="{ 'mb-0': !workflow.workflowID }"
>
<c-input-checkbox
v-model="workflow.enabled"
switch
:labels="checkboxLabel"
/>
</b-form-group>
</b-col>
</b-row>
<c-system-fields
:id="workflow.workflowID"
:resource="workflow"
/>
<!--
include hidden input to enable
trigger submit event w/ ENTER
-->
<input
type="submit"
class="d-none"
:disabled="saveDisabled"
>
</b-form>
<template #footer>
<c-input-confirm
v-if="workflow && workflow.workflowID && workflow.canDeleteWorkflow"
:text="getDeleteStatus"
:disabled="deleteDisabled"
variant="danger"
size="md"
@confirmed="$emit('delete')"
/>
<b-button
v-if="workflow.workflowID"
variant="light"
@click="openWorkflowBuilder()"
>
{{ $t('openBuilder') }}
</b-button>
<c-button-submit
:disabled="saveDisabled"
:processing="processing"
:success="success"
:text="$t('admin:general.label.submit')"
class="ml-auto"
@submit="$emit('submit', workflow)"
/>
</template>
</b-card>
</template>
<script>
import { handle } from '@cortezaproject/corteza-vue'
import { NoID } from '@cortezaproject/corteza-js'
export default {
name: 'CWorkflowEditorInfo',
i18nOptions: {
namespaces: 'automation.workflows',
keyPrefix: 'editor.info',
},
props: {
workflow: {
type: Object,
required: true,
},
processing: {
type: Boolean,
value: false,
},
success: {
type: Boolean,
value: false,
},
canCreate: {
type: Boolean,
required: true,
},
},
data () {
return {
checkboxLabel: {
on: this.$t('general:label.general.yes'),
off: this.$t('general:label.general.no'),
},
}
},
computed: {
editable () {
return (!this.workflow.workflowID || this.workflow.workflowID === NoID) || this.workflow.canUpdateWorkflow
},
nameState () {
return this.workflow.meta.name ? null : false
},
handleState () {
return handle.handleState(this.workflow.handle)
},
saveDisabled () {
return !this.editable || [this.nameState, this.handleState].includes(false)
},
deleteDisabled () {
return !(this.workflow.deletedAt ? this.workflow.canUndeleteWorkflow : this.workflow.canDeleteWorkflow)
},
getDeleteStatus () {
return this.workflow.deletedAt ? this.$t('undelete') : this.$t('delete')
},
},
methods: {
openWorkflowBuilder () {
window.open(`/workflow/${this.workflow.workflowID}/edit`, '_blank')
},
},
}
</script>

Some files were not shown because too many files have changed in this diff Show More