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

19
.editorconfig Normal file
View File

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

7
.eslintignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
api-clients/automation.ts
api-clients/compose.ts
api-clients/federation.ts
api-clients/system.ts
**/*.test.ts

10
.gitattributes vendored Normal file
View File

@@ -0,0 +1,10 @@
server/**/*gen.go linguist-generated=true
server/**/*gen_test.go linguist-generated=true
server/vendor/** linguist-vendored=true
server/go.mod linguist-vendored=true
server/go.sum linguist-vendored=true
server/docs/*.yaml linguist-documentation=true
server/docs/README.adoc linguist-documentation=true
server/docs/index.html linguist-documentation=true

103
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: Bug 🐞
description: File a bug/issue with Corteza
title: "When I try to do X I get Y"
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
If this is your first time, please read the following:
Take a quick look on how Github's [markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) works. It will help you format your message and make it joy to read.
Corteza is a complex application with many moving parts. You can help us a lot
by respecting the following instructions for bug reports.
# Web application bugs
When issue requires a certain steps to be followed, record a video, otherwise make a screenshot. If possible resize your window to a minimum size where application is still usable or make a screenshot or recording that focuses on a problem if possible and do not capture your whole screen. This keeps the video files smaller with contents, especially
errors, values readable.
## Favours and versions of your operating system and browsers
Corteza is thoroughly tested on recent versions of Chrome and Firefox. You might have some special combination that we did not cover. If possible, upgrade your system and browser first. It is usually a good thing to do. If you are not able to or the problem persists, please share the version details.
# Server errors
Sometimes the error happens between the request your browser sends and the response it gets. In such cases we would love to see all headers and parameters of that HTTP request and how the response looks like. Text form is perfect.
If you have full control over your server, try enabling full debug logging in your .env fil by setting `LOG_DEBUG=true` and `LOG_LEVEL=debug`, restarting your server and sharing relevant log lines.
As for logs: we usually just need the lines that are related to the request. But if your server does not start or crashes right after, make sure you share everything from first log line on. There might be some values in there you want to obfuscate before sharing.
## Large responses or huge log files
If you want to share something that exceeds 42 lines, try [Github Gist](https://gist.github.com/) and just add a link to Gist you created.
## Favours and versions of your operating system, Docker and Docker Compose
A big share of issues is unrelated to any of these. You can skip this when preparing your bug report. If you did some changes and experimenting with Corteza in such a low level let us know what version you need.
- type: input
attributes:
label: Version of Corteza
placeholder: 202Y.X.x
description: "You can make sure by visiting `/version` (e.g.: http://localhost:3000/version)"
validations:
required: false
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
2. With this config...
3. Run '...'
4. See error...
validations:
required: false
- type: textarea
attributes:
label: Environment and versions
description: |
When it is clear that the problem is related to a browser and is not something that happens on the server (between request and response), share your operating system you are using and browser with versions for each one of them.
render: markdown
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking on this area to highlight it and then by dragging files in it.
validations:
required: false

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Community Support
url: https://forum.cortezaproject.org/
about: Please ask and answer questions here.
- name: Ideas, enhancements and feature requests 🚀
url: https://github.com/orgs/cortezaproject/discussions
about: Please open a new discussion and describe the idea you have.

13
.github/PULL_REQUEST_TEMPLATE vendored Normal file
View File

@@ -0,0 +1,13 @@
# The following changes are implemented
TODO: Summary
# Changes in the user interface:
TODO: Add screenshots, recordings or remove this section
# Checklist when submitting a final (!draft) PR
- [ ] Commits are tidied up, squashed if needed and follow guidelines in CONTRIBUTING.md
- [ ] Code builds
- [ ] All existing tests pass
- [ ] All new critical code is covered by tests
- [ ] PR is linked to the relevant issue(s)
- [ ] Rebased with the target branch

1
.github/assets/corteza_logo.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 241.45 38.63"><defs><style>.cls-1{fill:#FF9661;}</style></defs><path class="cls-1" d="M2.51,9.38A18,18,0,0,1,9.37,2.51,19.49,19.49,0,0,1,19.13.05,19.15,19.15,0,0,1,30.21,3.3a17,17,0,0,1,6.64,9h-8.7A9,9,0,0,0,24.5,8.21a10.67,10.67,0,0,0-5.43-1.35A11.24,11.24,0,0,0,13.21,8.4a10.6,10.6,0,0,0-4,4.35,14.41,14.41,0,0,0-1.43,6.59,14.44,14.44,0,0,0,1.43,6.57,10.55,10.55,0,0,0,4,4.38,11.24,11.24,0,0,0,5.86,1.54,10.49,10.49,0,0,0,5.43-1.38,9.16,9.16,0,0,0,3.65-4.08h8.7a16.92,16.92,0,0,1-6.62,9,19.27,19.27,0,0,1-11.1,3.21,19.49,19.49,0,0,1-9.76-2.46,18,18,0,0,1-6.86-6.83A19.69,19.69,0,0,1,0,19.34,19.76,19.76,0,0,1,2.51,9.38Z"/><path class="cls-1" d="M50.43,36.15a18.53,18.53,0,0,1-7-6.89,19.28,19.28,0,0,1-2.59-10A19.13,19.13,0,0,1,43.4,9.38a18.46,18.46,0,0,1,7-6.89,20.29,20.29,0,0,1,19.48,0,18.37,18.37,0,0,1,7,6.89,19.31,19.31,0,0,1,2.56,9.91,19.45,19.45,0,0,1-2.56,10,18.38,18.38,0,0,1-7,6.89,20.31,20.31,0,0,1-19.45,0Zm15.72-5.81a10.53,10.53,0,0,0,4-4.4,14.38,14.38,0,0,0,1.46-6.65,14.2,14.2,0,0,0-1.46-6.62,10.4,10.4,0,0,0-4-4.35,12.72,12.72,0,0,0-12,0,10.34,10.34,0,0,0-4.08,4.35,14.32,14.32,0,0,0-1.46,6.62,14.51,14.51,0,0,0,1.46,6.65,10.47,10.47,0,0,0,4.08,4.4,12.5,12.5,0,0,0,12,0Z"/><path class="cls-1" d="M103.92,38.26,95.6,23.56H92v14.7H84.47V.54H98.62a16.49,16.49,0,0,1,7.46,1.54,10.8,10.8,0,0,1,4.62,4.16,11.32,11.32,0,0,1,1.54,5.86,11.23,11.23,0,0,1-2.16,6.73A11,11,0,0,1,103.65,23l9,15.3ZM92,17.89h6.32A6.33,6.33,0,0,0,103,16.4a5.52,5.52,0,0,0,1.51-4.13,5.29,5.29,0,0,0-1.51-4,6.5,6.5,0,0,0-4.6-1.43H92Z"/><path class="cls-1" d="M142.88.54V6.65h-10V38.26h-7.57V6.65H115.21V.54Z"/><path class="cls-1" d="M155.32,6.65V16.1H168v6h-12.7v10h14.32v6.16H147.75V.49h21.89V6.65Z"/><path class="cls-1" d="M185.42,31.88h17v6.38H176.66V32.42l16.86-25.5H176.66V.54h25.72V6.38Z"/><path class="cls-1" d="M231,31.07H216l-2.49,7.19h-7.94L219.08.49h8.81l13.56,37.77h-8ZM228.91,25,223.46,9.24,218,25Z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,4 @@
// Corteza API location
window.CortezaAPI = '//localhost:8888/api'
window.i18nPseudoModeEnabled = false

View File

@@ -0,0 +1,242 @@
const webpack = require('webpack')
const exec = require('child_process').execSync
const path = require('path')
const Vue = require('vue')
module.exports = ({ appFlavour, appLabel, version = process.env.BUILD_VERSION, theme, packageAlias, root = path.resolve('.'), env = process.env.NODE_ENV }) => {
const isDevelopment = (env === 'development')
const isTest = (env === 'test')
if (isTest) {
Vue.config.devtools = false
Vue.config.productionTip = false
}
if (isDevelopment) {
Vue.config.devtools = true
Vue.config.performance = true
}
const optimization = isTest
? {}
: {
usedExports: true,
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
minSize: 20000,
maxSize: 244000,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: -10,
},
common: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
}
return {
publicPath: isDevelopment ? '/' : './',
lintOnSave: true,
runtimeCompiler: true,
configureWebpack: {
// other webpack options to merge in ...
// Webpack 5 specific configuration
resolve: {
symlinks: false,
fallback: {
path: false,
fs: false, // Add explicit false for Node.js core modules
crypto: false,
stream: false,
util: false,
},
},
plugins: [
new webpack.DefinePlugin({
FLAVOUR: JSON.stringify(appFlavour),
WEBAPP: JSON.stringify(appLabel),
VERSION: JSON.stringify(version || ('' + exec('git describe --always --tags')).trim()),
BUILD_TIME: JSON.stringify((new Date()).toISOString()),
}),
],
optimization: {
...optimization,
moduleIds: 'deterministic',
chunkIds: 'named',
emitOnErrors: false,
},
stats: {
warnings: false,
errors: false,
},
performance: {
hints: isDevelopment ? false : 'warning',
maxEntrypointSize: 512000,
maxAssetSize: 512000,
},
},
chainWebpack: config => {
// https://cli.vuejs.org/guide/troubleshooting.html#symbolic-links-in-node-modules
config.resolve.symlinks(false)
// Remove css extraction issues
// https://github.com/vuejs/vue-cli/issues/3771#issuecomment-526228100
config.plugin('friendly-errors').tap(args => {
const vueCli3Transformer = args[0].additionalTransformers[0]
args[0].additionalTransformers = [
vueCli3Transformer,
error => {
return /\[mini-css-extract-plugin\]/.test(error.message) ? {} : error
},
]
return args
})
// Update copy-webpack-plugin configuration for v8+
config.plugins.has('copy') && config.plugin('copy').tap(options => {
options[0].patterns = [{
from: 'public',
globOptions: {
ignore: ['**/config*js', '**/*gitignore', '**/index.html'],
},
noErrorOnMissing: true,
}]
return options
})
// Aliasing full package name instead of '@' so we do
// not break imports on apps that import this code
config.resolve.alias.delete('@')
if (packageAlias) {
config.resolve.alias.set(packageAlias, root)
}
if (isTest) {
const scssRule = config.module.rule('scss')
scssRule.uses.clear()
scssRule
.use('null-loader')
.loader('null-loader')
}
const scssNormal = config.module.rule('scss').oneOf('normal')
scssNormal.use('sass-loader')
.loader('sass-loader')
.tap(options => ({
...options,
sourceMap: true,
sassOptions: {
outputStyle: isDevelopment ? 'expanded' : 'compressed',
},
}))
// Load CSS assets according to their location
scssNormal.use('resolve-url-loader')
.loader('resolve-url-loader').options({
keepQuery: true,
removeCR: true,
root: path.join(root, 'src/themes', theme),
})
.before('sass-loader')
// Keep this to ensure we don't have multiple HTML plugins
},
devServer: {
host: '127.0.0.1',
port: 8080,
hot: true,
allowedHosts: 'all', // replaces disableHostCheck
webSocketServer: 'ws',
headers: {
'Access-Control-Allow-Origin': '*',
},
proxy: {
'^/custom.css': {
target: fetchBaseUrl(),
},
'^/code-snippets.js': {
target: fetchBaseUrl(),
},
},
// Webpack 5 DevServer configuration
watchFiles: {
paths: [
'**/*',
'!**/node_modules/!(@cortezaproject)/**',
],
options: {
usePolling: true,
aggregateTimeout: 200,
poll: 1000,
},
},
client: {
overlay: false,
progress: true,
},
},
css: {
sourceMap: isDevelopment,
extract: !isTest,
loaderOptions: {
sass: {
sassOptions: {
outputStyle: isDevelopment ? 'expanded' : 'compressed',
},
},
postcss: {
postcssOptions: {
plugins: [
require('autoprefixer'),
require('rtlcss'),
],
},
},
},
},
}
}
function fetchBaseUrl () {
const fs = require('fs')
const window = {}
const fileContents =
fs.existsSync('public/config.js')
? fs.readFileSync('public/config.js', 'utf-8')
: ''
try {
// eslint-disable-next-line no-eval
eval(fileContents)
const u = window.CortezaAPI || ''
const ur = new URL(u.startsWith('//') ? `http:${u}` : u)
return `${ur.protocol}//${ur.host}/`
} catch (e) {
return '/'
}
}

View File

@@ -0,0 +1,62 @@
version: "3.5"
services:
backend:
network_mode: host
image: golang:${GO_VERSION}
volumes:
- "${PWD}:/corteza"
entrypoint: |
sh -c "
git config --global --add safe.directory /corteza &&
go run /corteza/server/cmd/corteza/main.go serve-api
"
working_dir: /corteza/server
environment:
- DB_DSN=postgres://corteza:root@localhost:5432/corteza_cy_test?sslmode=disable
- HTTP_ADDR=:8888
- HTTP_WEBAPP_ENABLED=true
- ENVIRONMENT=dev
- AUTH_REQUEST_RATE_LIMIT=0
- FEDERATION_ENABLED=true
- FEDERATION_HOST=http://corteza
- FEDERATION_LABEL=Corteza e2e federation host
healthcheck:
test: curl --silent --fail-early --fail http://localhost:8888/healthcheck || exit 1
interval: 30s
timeout: 30s
retries: 3
start_period: 60s
postgres:
network_mode: host
image: postgres:15
environment:
POSTGRES_USER: corteza
POSTGRES_PASSWORD: root
POSTGRES_DB: corteza_cy_test
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
client:
network_mode: host
image: node:22-slim
volumes:
- "${PWD}:/corteza"
working_dir: /corteza
environment:
BUILD_VERSION: 2024.9.10
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:8080 || exit 1
interval: 30s
timeout: 30s
retries: 3
start_period: 60s
deploy:
resources:
limits:
memory: 2g

517
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,517 @@
name: "Release"
# These jobs can be tested with nektos/act tool
# https://github.com/nektos/act
#
# Look for "!env.ACT" in the DRY_RUN expression below
on:
push:
tags:
- '**'
env:
BUILD_OS: linux
BUILD_ARCH: amd64
GO_VERSION: 1.24.1
GOFLAGS: -mod=readonly
NODE_VERSION: 22
RELEASE_BASE_URL: "https://releases.cortezaproject.org/files"
SASS_VERSION: 1.85.1
jobs:
server-test:
name: "Server tests"
runs-on: ubuntu-22.04
env:
GOFLAGS: -mod=readonly
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with: { go-version: "${{ env.GO_VERSION }}" }
- name: "Copy language files"
working-directory: server/pkg/locale
run: make src/en
- name: "Unit"
working-directory: server
run: make test.unit
- name: "Store"
working-directory: server
run: make test.store
- name: "Integration"
working-directory: server
run: make test.integration
# Building web console for server and cache it for release-* steps
# we'll do this in parallel with tests to gain a few seconds
server-web-console-build:
name: "Server Web Console Build"
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with: { node-version: "${{ env.NODE_VERSION }}" }
- uses: actions/cache@v3
if: ${{ !env.ACT }}
with:
path: ~/.npm
key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.OS }}-node-
- name: "Setup YARN"
run: npm install -g yarn @vue/cli-service
- name: "Install dependencies"
working-directory: server/webconsole
run: yarn install
- name: "Build Package"
working-directory: server/webconsole
run: yarn build
- name: "Cache console build"
uses: actions/cache@v3
with: { path: ./server/webconsole/dist, key: console-build }
server-release:
name: "Server Release (${{ matrix.os }})"
strategy:
fail-fast: true
matrix:
include:
- { runsOn: "ubuntu-22.04", os: "linux" }
- { runsOn: "macos-latest", os: "darwin" }
runs-on: ${{ matrix.runsOn }}
needs: [ server-web-console-build, server-test ]
env:
BUILD_OS: ${{ matrix.os }}
RELEASE_SFTP_KEY: ${{ secrets.RELEASE_SFTP_KEY }}
RELEASE_SFTP_URI: ${{ secrets.RELEASE_SFTP_URI }}
steps:
- run: echo "BUILD_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with: { go-version: "${{ env.GO_VERSION }}" }
- name: "Restore web console dist from cache"
uses: actions/cache@v3
with: { path: ./server/webconsole/dist, key: console-build }
- name: "Copy essentials"
run: cp *.md DCO LICENSE server/
- name: "Copy language files"
working-directory: server/pkg/locale
run: make src/en src/de src/fr
- name: "Build"
working-directory: server
run: make release-clean release
- name: "Upload"
working-directory: server
run: make upload
if: ${{ !env.ACT }}
########################################################################################################################
########################################################################################################################
# Libs
lib-test:
name: "Library test (${{ matrix.lib }})"
runs-on: ubuntu-22.04
strategy:
matrix:
lib: [ "js", "vue" ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with: { node-version: "${{ env.NODE_VERSION }}" }
- uses: actions/cache@v3
if: ${{ !env.ACT }}
with:
path: ~/.npm
key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.OS }}-node-${{ matrix.lib }}
- name: "Setup YARN"
run: npm install -g yarn @vue/cli-service
- name: "Install dependencies"
working-directory: lib/${{ matrix.lib }}
run: yarn install
- name: "Run all tests"
working-directory: lib/${{ matrix.lib }}
run: yarn test:unit
lib-build-and-publish:
name: "Publish libraries (${{ matrix.lib }})"
runs-on: ubuntu-22.04
needs: [ lib-test ]
strategy:
matrix:
lib: [ "js", "vue" ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
registry-url: 'https://registry.npmjs.org/'
node-version: "${{ env.NODE_VERSION }}"
- uses: actions/cache@v3
if: ${{ !env.ACT }}
with:
path: ~/.npm
key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.OS }}-node-${{ matrix.lib }}
- name: "Setup YARN"
run: npm install -g yarn @vue/cli-service
- name: "Install dependencies"
working-directory: lib/${{ matrix.lib }}
run: yarn install
- name: "Build Package"
working-directory: lib/${{ matrix.lib }}
run: yarn build
- name: "Dry Run Publish"
working-directory: lib/${{ matrix.lib }}
run: npm publish --dry-run
# invalid secret?
# - name: "Publish"
# working-directory: lib/${{ matrix.lib }}
# run: npm publish
# env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" }
# if: ${{ !env.ACT }}
########################################################################################################################
########################################################################################################################
# Web clients
client-web-test:
name: "Web client test (${{ matrix.app }})"
runs-on: ubuntu-22.04
strategy:
fail-fast: true
matrix: { app: [ admin, compose, discovery, privacy, reporter, one, workflow ] }
needs: [ "lib-build-and-publish" ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with: { node-version: "${{ env.NODE_VERSION }}" }
- name: "Setup YARN"
working-directory: client/web/${{ matrix.app }}
run: npm install -g yarn @vue/cli-service
- name: "Cache"
uses: actions/cache@v3
if: ${{ !env.ACT }}
with:
path: ~/.npm
key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.OS }}-node-
- name: "Dependencies"
working-directory: client/web/${{ matrix.app }}
run: make dep
- name: "Tests"
working-directory: client/web/${{ matrix.app }}
run: make test
client-web-release:
name: "Web client (${{ matrix.app }})"
runs-on: ubuntu-22.04
strategy:
fail-fast: true
matrix: { app: [ admin, compose, discovery, privacy, reporter, one, workflow ] }
needs: [ client-web-test ]
env:
RELEASE_SFTP_KEY: ${{ secrets.RELEASE_SFTP_KEY }}
RELEASE_SFTP_URI: ${{ secrets.RELEASE_SFTP_URI }}
steps:
- run: echo "BUILD_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with: { node-version: "${{ env.NODE_VERSION }}" }
- name: "Setup YARN"
run: npm install -g yarn @vue/cli-service
- name: "Cache"
uses: actions/cache@v3
if: ${{ !env.ACT }}
with:
path: ~/.npm
key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.OS }}-node-
- name: "Dependencies"
working-directory: client/web/${{ matrix.app }}
run: make dep
- name: "Build"
working-directory: client/web/${{ matrix.app }}
run: make build
- name: "Copy essentials"
run: cp *.md DCO LICENSE client/web/${{ matrix.app }}
- name: "Release"
working-directory: client/web/${{ matrix.app }}
run: make release
- name: "Upload"
working-directory: client/web/${{ matrix.app }}
run: make upload
if: ${{ !env.ACT }}
########################################################################################################################
client-web-aio-release:
name: "Web clients (all-in-one release)"
runs-on: ubuntu-22.04
needs: [ client-web-release ]
env:
RELEASE_SFTP_KEY: ${{ secrets.RELEASE_SFTP_KEY }}
RELEASE_SFTP_URI: ${{ secrets.RELEASE_SFTP_URI }}
steps:
- uses: actions/checkout@v3
- run: echo "BUILD_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: "Prepare dist dir"
run: |
mkdir -p \
client/web/dist/admin \
client/web/dist/compose \
client/web/dist/workflow \
client/web/dist/reporter \
client/web/dist/discovery \
client/web/dist/privacy
- name: "Download & unpack one"
run: |
curl --location "${{ env.RELEASE_BASE_URL }}/corteza-webapp-one-${{ env.BUILD_VERSION }}.tar.gz" | \
tar -xzmok -C client/web/dist
- name: "Download & unpack admin"
run: |
curl --silent --location "${{ env.RELEASE_BASE_URL }}/corteza-webapp-admin-${{ env.BUILD_VERSION }}.tar.gz" | \
tar -xzmok -C client/web/dist/admin
- name: "Download & unpack compose"
run: |
curl --silent --location "${{ env.RELEASE_BASE_URL }}/corteza-webapp-compose-${{ env.BUILD_VERSION }}.tar.gz" | \
tar -xzmok -C client/web/dist/compose
- name: "Download & unpack workflow"
run: |
curl --silent --location "${{ env.RELEASE_BASE_URL }}/corteza-webapp-workflow-${{ env.BUILD_VERSION }}.tar.gz" | \
tar -xzmok -C client/web/dist/workflow
- name: "Download & unpack reporter"
run: |
curl --silent --location "${{ env.RELEASE_BASE_URL }}/corteza-webapp-reporter-${{ env.BUILD_VERSION }}.tar.gz" | \
tar -xzmok -C client/web/dist/reporter
- name: "Download & unpack discovery"
run: |
curl --silent --location "${{ env.RELEASE_BASE_URL }}/corteza-webapp-discovery-${{ env.BUILD_VERSION }}.tar.gz" | \
tar -xzmok -C client/web/dist/discovery
- name: "Download & unpack privacy"
run: |
curl --silent --location "${{ env.RELEASE_BASE_URL }}/corteza-webapp-privacy-${{ env.BUILD_VERSION }}.tar.gz" | \
tar -xzmok -C client/web/dist/privacy
- name: "Copy essentials"
run: cp *.md DCO LICENSE client/web/dist/
- name: "Pack all web clients"
working-directory: client/web
run: tar -C dist -czf corteza-webapp-${{ env.BUILD_VERSION }}.tar.gz $(dir dist)
- name: "Upload"
working-directory: client/web
run: |
echo ${{ env.RELEASE_SFTP_KEY }} | base64 -d > .upload-rsa && chmod 0400 .upload-rsa
echo "put corteza-webapp-${{ env.BUILD_VERSION }}.tar.gz" | \
sftp -q -o "StrictHostKeyChecking no" -i .upload-rsa ${{ env.RELEASE_SFTP_URI }}
rm -f .upload-rsa
if: ${{ !env.ACT }}
########################################################################################################################
########################################################################################################################
# This is where it all comes together
release:
name: "Corteza (${{ matrix.os }})"
runs-on: ubuntu-22.04
needs: [ server-release, client-web-aio-release ]
strategy:
fail-fast: true
matrix:
os: [ "linux" ]
env:
ENDPOINT: https://releases.cortezaproject.org/files
RELEASE_SFTP_KEY: ${{ secrets.RELEASE_SFTP_KEY }}
RELEASE_SFTP_URI: ${{ secrets.RELEASE_SFTP_URI }}
steps:
- run: echo "BUILD_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- uses: actions/checkout@v3
- name: "Download"
run: |
curl --silent --location "${{ env.RELEASE_BASE_URL }}/corteza-server-${{ env.BUILD_VERSION }}-${{ matrix.os }}-amd64.tar.gz" | \
tar -xzmok -C .
mv corteza-server dist
rm -rf dist/webapp
mkdir dist/webapp
curl --silent --location "${{ env.RELEASE_BASE_URL }}/corteza-webapp-${{ env.BUILD_VERSION }}.tar.gz" | \
tar -xzmok -C dist/webapp
- name: "Pack"
run: |
tar -C dist -czf corteza-${{ env.BUILD_VERSION }}-linux-amd64.tar.gz $(dir dist)
- name: "Upload"
run: |
echo ${{ env.RELEASE_SFTP_KEY }} | base64 -d > .upload-rsa
chmod 0400 .upload-rsa
echo "put corteza-${{ env.BUILD_VERSION }}-linux-amd64.tar.gz" | \
sftp -q -o "StrictHostKeyChecking no" -i .upload-rsa ${{ env.RELEASE_SFTP_URI }}
rm -f .upload-rsa
if: ${{ !env.ACT }}
########################################################################################################################
########################################################################################################################
# Discovery
extra-server-discovery-test:
name: "Discovery Server test"
runs-on: ubuntu-22.04
steps:
- name: "Placeholder"
run: echo "@todo add tests"
extra-server-discovery-release:
name: "Discovery Server release ${{ matrix.os }}"
strategy:
fail-fast: true
matrix:
include:
- { runsOn: "ubuntu-22.04", os: "linux" }
- { runsOn: "macos-latest", os: "darwin" }
runs-on: ${{ matrix.runsOn }}
needs: [ extra-server-discovery-test ]
env:
BUILD_OS: ${{ matrix.os }}
BUILD_VERSION: ${{ format(github.ref, 'refs/tags/', '') }}
RELEASE_SFTP_KEY: ${{ secrets.RELEASE_SFTP_KEY }}
RELEASE_SFTP_URI: ${{ secrets.RELEASE_SFTP_URI }}
steps:
- run: echo "BUILD_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with: { go-version: "${{ env.GO_VERSION }}" }
- name: "Copy essentials"
run: cp *.md DCO LICENSE extra/server-discovery/
- name: "Build & release"
working-directory: extra/server-discovery/
run: make release-clean release
- name: "Upload"
working-directory: extra/server-discovery/
run: make upload
if: ${{ !env.ACT }}
########################################################################################################################
docker-images:
name: "Docker image ${{ matrix.image }}"
runs-on: ubuntu-22.04
needs:
- server-release
- client-web-aio-release
- extra-server-discovery-release
strategy:
fail-fast: true
matrix:
include:
- { context: "extra/server-discovery", image: "cortezaproject/corteza-server-discovery" }
- { context: "client/web", image: "cortezaproject/corteza-webapp" }
- { context: "server", image: "cortezaproject/corteza-server" }
- { context: ".", image: "cortezaproject/corteza" }
steps:
- uses: actions/checkout@v3
- name: "Parse version tag"
id: version
run: |
TAG=${GITHUB_REF#refs/tags/}
echo "DOCKER_IMAGE_TAG=${TAG}" >> $GITHUB_ENV
if [[ "$(echo ${TAG} | grep '-')" == "" ]]; then
# when releasing patched version (YYYY.MM.PATCH) we
# tag alias (YYYY.MM) as well
echo "DOCKER_IMAGE_ALIAS=$(echo ${TAG} | cut -d '.' -f -2)" >> $GITHUB_ENV
fi
- name: "Build"
run: |
docker build \
--build-arg "VERSION=${{ env.DOCKER_IMAGE_TAG }}" \
--build-arg "SASS_VERSION=${{ env.SASS_VERSION }}" \
--tag ${{ matrix.image }}:${{ env.DOCKER_IMAGE_TAG }} \
${{ matrix.context }}
- name: "Login"
uses: docker/login-action@v2
if: ${{ !env.ACT && env.DOCKER_IMAGE_TAG }}
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Push ${{ env.DOCKER_IMAGE_TAG }}"
if: ${{ !env.ACT }}
run: |
docker push \
${{ matrix.image }}:${{ env.DOCKER_IMAGE_TAG }}
- name: "Push ${{ env.DOCKER_IMAGE_ALIAS }}"
if: ${{ !env.ACT && env.DOCKER_IMAGE_ALIAS }}
run: |
docker tag \
${{ matrix.image }}:${{ env.DOCKER_IMAGE_TAG }} \
${{ matrix.image }}:${{ env.DOCKER_IMAGE_ALIAS }}
docker push \
${{ matrix.image }}:${{ env.DOCKER_IMAGE_ALIAS }}
notify:
name: "Send matrix release notification"
runs-on: ubuntu-22.04
needs: [ docker-images, release ]
steps:
- uses: actions/checkout@v3
- name: "Parse version tag"
id: version
run: |
TAG=${GITHUB_REF#refs/tags/}
echo "DOCKER_IMAGE_TAG=${TAG}" >> $GITHUB_ENV
- name: "Send message via Matrix on successful release"
if: ${{ !env.ACT }}
id: matrix-chat-release-successful
uses: fadenb/matrix-chat-message@v0.0.6
with:
homeserver: ${{ secrets.MATRIX_HOME_SERVER }}
token: ${{ secrets.MATRIX_ACCESS_TOKEN }}
channel: ${{ secrets.MATRIX_ROOM_ID }}
message: |
# Corteza **${{ env.DOCKER_IMAGE_TAG }}** has been successfully released 🎉🎉
- name: "Send message via Matrix on failed release"
if: ${{ !env.ACT && failure() }}
id: matrix-chat-release-failed
uses: fadenb/matrix-chat-message@v0.0.6
with:
homeserver: ${{ secrets.MATRIX_HOME_SERVER }}
token: ${{ secrets.MATRIX_ACCESS_TOKEN }}
channel: ${{ secrets.MATRIX_ROOM_ID }}
message: |
# Corteza **${{ env.DOCKER_IMAGE_TAG }}** release has failed
publish-release-sentry:
name: "Publish release to sentry"
runs-on: ubuntu-22.04
needs: [ notify ]
steps:
- uses: actions/checkout@v3
- name: Create Sentry release
uses: getsentry/action-release@v1
continue-on-error: true
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
ignore_empty: true
ignore_missing: true
version: ${{ github.ref_name }}

19
.github/workflows/sonar.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: SonarQube
on:
push:
branches:
- '20[0-9][0-9].[0-9].x'
pull_request:
types: [opened, synchronize, reopened]
jobs:
sonarqube:
name: SonarQube
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v4
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

27
.github/workflows/stale-issues.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
#
# You can adjust the behavior by modifying this file.
# For more information, see:
# https://github.com/actions/stale
name: Mark stale issues and pull requests
on:
schedule:
- cron: '0 0 1 * *'
jobs:
stale:
runs-on: ubuntu-22.04
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'Stale issue message'
stale-pr-message: 'Stale pull request message'
stale-issue-label: 'no-issue-activity'
stale-pr-label: 'no-pr-activity'
days-before-close: -1

224
.github/workflows/test-e2e.yml vendored Normal file
View File

@@ -0,0 +1,224 @@
name: 'E2e test on main branch push'
# These jobs can be tested with nektos/act tool
# https://github.com/nektos/act
#
# Look for "!env.ACT" in the DRY_RUN expression below
on:
push:
branches:
- '20[0-9][0-9].[0-9].x'
- '20[0-9][0-9].[0-9].[0-9]'
env:
BUILD_OS: linux
BUILD_ARCH: amd64
GO_VERSION: 1.24.1
GOFLAGS: -mod=readonly
NODE_VERSION: 22
CYPRESS_VERSION: 13.5.0
CYPRESS_HOST: http://localhost:8888
CYPRESS_BASE_URL: http://localhost:8888
CYPRESS_ADMIN_URL: http://localhost:8080
CYPRESS_COMPOSE_URL: http://localhost:8081
CYPRESS_WORKFLOW_URL: http://localhost:8082
CYPRESS_REPORTER_URL: http://localhost:8083
CYPRESS_ONE_URL: http://localhost:8086
CYPRESS_PRIVACY_URL: http://localhost:8087
CYPRESS_USER_EMAIL: cypress@test.com
CYPRESS_USER_PASSWORD: cypress123
CYPRESS_USER_PASSWORD_NEW: newcypress123
CYPRESS_USER_EMAIL_NEW: permissions@email.com
CYPRESS_USER_DPO: dpo@email.com
CYPRESS_USER_DPO_PASSWORD: dpo@email.com
CYPRESS_PROVISION_DB_DSN: postgres://corteza:root@localhost:5432/corteza_cy_test?sslmode=disable
jobs:
server-client-setup:
name: server and client test
strategy:
matrix:
client:
- { port: 8080, name: admin }
# todo - these tests must first be provisioned in order to work,
# only admin for now
# - { port: 8080, name: server }
# - { port: 8081, name: compose }
# - { port: 8082, name: workflow }
# - { port: 8083, name: reporter }
# - { port: 8086, name: one }
# - { port: 8087, name: privacy }
runs-on: ubuntu-22.04
steps:
- name: Setup environment
env:
RELEASE_CRUST_SFTP_KEY: ${{ secrets.RELEASE_CRUST_SFTP_KEY }}
RELEASE_CRUST_SFTP_URI: ${{ secrets.RELEASE_CRUST_SFTP_URI }}
CLIENT_NAME: ${{ matrix.client.name }}
run: |
echo "TMP_KEY=$(mktemp)" >> $GITHUB_ENV
- name: Create crust key
env:
RELEASE_CRUST_SFTP_KEY: ${{ secrets.RELEASE_CRUST_SFTP_KEY }}
RELEASE_CRUST_KEY_FILE: ${{ env.TMP_KEY }}
run: |
echo ${RELEASE_CRUST_SFTP_KEY} | base64 -d > ${RELEASE_CRUST_KEY_FILE} && \
echo ${RELEASE_CRUST_SFTP_KEY} > /tmp/foo
- uses: actions/setup-node@v3
with: { node-version: '${{ env.NODE_VERSION }}' }
- uses: actions/setup-go@v3
with: { go-version: '${{ env.GO_VERSION }}' }
- name: Setup YARN
run: npm install -g yarn @vue/cli-service
- name: Checkout corteza repo
uses: actions/checkout@v3
with:
path: corteza
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.ref_name }}
- name: Clone CY repo
uses: actions/checkout@v3
with:
repository: cortezaproject/corteza-e2e-cypress
token: ${{ secrets.GITHUB_TOKEN }}
ref: 2024.9.x
path: corteza-e2e-cypress
- name: Cache lib dependencies
uses: actions/cache@v3
with:
path: |
corteza/lib/vue/node_modules
corteza/lib/js/node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('corteza/lib/vue/yarn.lock', 'corteza/lib/js/yarn.lock') }}
- name: Copy server language files
working-directory: corteza/server/pkg/locale
run: make src/en
- name: Copy assets
working-directory: corteza
env:
CLIENT_NAME: ${{ matrix.client.name }}
run: |
mv ./.github/workflows/assets/docker-compose.yaml ./
cp .github/workflows/assets/client/vue.config-builder.js client/web/${CLIENT_NAME}/public/vue.config-builder.js
cp .github/workflows/assets/client/config.js client/web/${CLIENT_NAME}/public/config.js
docker compose config > /tmp/docker-compose.corteza.yaml
cd ../corteza-e2e-cypress && docker compose config > /tmp/docker-compose.corteza-e2e.yaml
# - name: Cache Docker images
# uses: ScribeMD/docker-cache@0.3.7
# with:
# key: docker-${{ runner.os }}-${{ hashFiles('/tmp/docker-compose.corteza.yaml', '/tmp/docker-compose.corteza-e2e.yaml') }}
# # Only cache Corteza-specific images, not common base images already on runners
# images: |
# cypress/included:13.5.0
- name: Run server and db
working-directory: corteza
run: |
docker compose run -d --name backend backend
docker compose run -d --name postgres postgres
- name: Build js
working-directory: corteza/lib/js
run: |
yarn install
yarn build
- name: Build vue
working-directory: corteza/lib/vue
run: |
yarn install
yarn build
- name: Run ${{ matrix.client.name }}
working-directory: corteza
# re-link the packages for each app
env:
CLIENT_NAME: ${{ matrix.client.name }}
CLIENT_PORT: ${{ matrix.client.port }}
run: |
docker compose \
run -d \
--name "${CLIENT_NAME}" \
--use-aliases \
client \
sh -c "ls && cd /corteza/lib/js && yarn link && \
cd /corteza/lib/vue && yarn link && \
cd /corteza/client/web/${CLIENT_NAME} && \
yarn link @cortezaproject/corteza-vue && \
yarn link @cortezaproject/corteza-js && \
yarn install && \
yarn serve --port ${CLIENT_PORT}"
# github actions do not support docker-compose --wait
- name: Wait for postgres healthcheck
run: timeout 180s sh -c 'until docker ps | grep postgres | grep -q healthy; do echo "Waiting for container to be healthy..."; sleep 1; done'
- name: Wait for ${{ matrix.client.name }} healthcheck
env:
CLIENT_NAME: ${{ matrix.client.name }}
run: |
timeout 180s sh -c 'until docker ps | grep ${CLIENT_NAME} | grep -q healthy; do echo "Waiting for container to be healthy..."; sleep 1; done'
- name: Wait for backend healthcheck
run: timeout 180s sh -c 'until docker ps | grep backend | grep -q healthy; do echo "Waiting for container to be healthy..."; sleep 1; done'
- name: Run CY tests for ${{ matrix.client.name }}
env:
CLIENT_NAME: ${{ matrix.client.name }}
CLIENT_PORT: ${{ matrix.client.port }}
working-directory: corteza-e2e-cypress
run: |
yarn install
docker compose run \
--entrypoint="bash -c \" \
cypress run --spec cypress/e2e/basic-functionalities/${CLIENT_NAME}/index.cy.js --browser chrome --config video=false,screenshotOnRunFailure=false \
\"" \
cypress
- name: Publish results
# always() runs the step even when canceled manually
if: success() || failure()
working-directory: corteza-e2e-cypress
env:
RELEASE_CRUST_SFTP_URI: ${{ secrets.RELEASE_CRUST_SFTP_URI }}
RELEASE_CRUST_KEY_FILE: ${{ env.TMP_KEY }}
run: |
npx mochawesome-merge "cypress/reports/*.json" > cypress/report.json && \
npx marge -o cypress/reports-build -f index.html -i cypress/report.json && \
echo "put cypress/reports-build/index.html" | sftp -q -o "StrictHostKeyChecking no" -i ${RELEASE_CRUST_KEY_FILE} ${RELEASE_CRUST_SFTP_URI}
notify-failure:
needs: [server-client-setup]
runs-on: ubuntu-22.04
if: failure()
steps:
- name: Send message on failed testing results
uses: fadenb/matrix-chat-message@v0.0.6
env:
E2E_REPORTS_USER: ${{ secrets.E2E_REPORTS_USER }}
E2E_REPORTS_PASS: ${{ secrets.E2E_REPORTS_PASS }}
SCREENSHOTS_GEN_FOLDER: ${{ env.SCREENSHOTS_GEN_FOLDER }}
with:
homeserver: ${{ secrets.MATRIX_HOME_SERVER }}
token: ${{ secrets.MATRIX_ACCESS_TOKEN }}
channel: ${{ secrets.MATRIX_ROOM_ID }}
message: |
❌ E2e tests did not pass
🔗 https://${{ secrets.E2E_REPORTS_USER }}:${{ secrets.E2E_REPORTS_PASS }}@releases.cortezaproject.org/e2e-reports/

115
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,115 @@
name: "Server and lib tests"
# These jobs can be tested with nektos/act tool
# https://github.com/nektos/act
#
# Look for "!env.ACT" in the DRY_RUN expression below
on:
pull_request:
types: [opened, reopened, synchronize]
push:
branches:
- '20[0-9][0-9].[0-9].x'
- '20[0-9][0-9].[0-9].[0-9]'
env:
BUILD_OS: linux
BUILD_ARCH: amd64
GO_VERSION: 1.24.1
GOFLAGS: -mod=readonly
WORKFLOW_STACK_TRACE_FULL: true
NODE_VERSION: 22
jobs:
lib-test:
name: "Library test (${{ matrix.lib }})"
runs-on: ubuntu-22.04
strategy:
matrix:
lib: [ "js", "vue" ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with: { node-version: "${{ env.NODE_VERSION }}" }
- uses: actions/cache@v3
if: ${{ !env.ACT }}
with:
path: ~/.npm
key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.OS }}-node-${{ matrix.lib }}
- name: "Setup YARN"
run: npm install -g yarn @vue/cli-service
- name: "Install dependencies"
working-directory: lib/${{ matrix.lib }}
run: yarn install
- name: "Run all tests"
working-directory: lib/${{ matrix.lib }}
run: yarn test:unit
client-web-test:
name: "Web client test (${{ matrix.app }})"
runs-on: ubuntu-22.04
strategy:
matrix: { app: [ admin, compose, discovery, privacy, reporter, one, workflow ] }
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with: { node-version: "${{ env.NODE_VERSION }}" }
- name: "Setup YARN"
working-directory: client/web/${{ matrix.app }}
run: npm install -g yarn @vue/cli-service
- name: "Cache"
uses: actions/cache@v3
if: ${{ !env.ACT }}
with:
path: ~/.npm
key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.OS }}-node-
- name: "Dependencies"
working-directory: client/web/${{ matrix.app }}
run: make dep
- name: "Tests"
working-directory: client/web/${{ matrix.app }}
run: make test
server-test:
name: "Server tests"
runs-on: ubuntu-22.04
env:
GOFLAGS: -mod=readonly
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with: { go-version: "${{ env.GO_VERSION }}" }
- name: "Copy language files"
working-directory: server/pkg/locale
run: make src/en
- name: "Unit"
working-directory: server
run: make test.unit
- name: "Store"
working-directory: server
run: make test.store
- name: "Integration"
working-directory: server
run: make test.integration
notify:
if: failure()
name: "Send matrix testing notification"
runs-on: ubuntu-22.04
needs: [ server-test, client-web-test, lib-test ]
steps:
- name: "Send message via Matrix on failed tests"
if: ${{ !env.ACT && failure() }}
id: matrix-chat-tests-failed
uses: fadenb/matrix-chat-message@v0.0.6
with:
homeserver: ${{ secrets.MATRIX_HOME_SERVER }}
token: ${{ secrets.MATRIX_ACCESS_TOKEN }}
channel: ${{ secrets.MATRIX_ROOM_ID }}
message: |
# Corteza **${GITHUB_REF##*/}** tests have failed

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
.DS_Store
.idea
.vscode
*.iml
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
.run
node_modules
ignore

69
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,69 @@
# Contributing
Thank you for helping us make [our vision](https://cortezaproject.org/about/what-is-corteza/) a reality!
All contributions are welcome; from bug reports, codefixes, and new features!
## Ground Rules
Corteza projects are [Apache 2.0 licensed](LICENSE) and accept contributions via GitHub pull requests.
Cover the [terminology](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/release-cycle/index.html#_terminology) for the development process and versioning.
Cover the [Git and GitHub](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/release-cycle/index.html#_github) ground rules regarding branch naming and conventions.
When you wish to start working on a code contribution, assign yourself to a GitHub issue.
If there is no issue, create one beforehand.
A quick summary on [How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/):
1. Separate subject from body with a blank line
2. Limit the subject line to 50 characters
3. Capitalize the subject line
4. Do not end the subject line with a period
5. Use the imperative mood in the subject line
6. Wrap the body at 72 characters
7. Use the body to explain what and why vs. how
## Core repositories
### Corteza Server
Corteza server is the back-end of the Corteza ecosystem.
The core logic is written in GO, using [go-chi](https://pkg.go.dev/github.com/go-chi/chi@v3.3.4+incompatible?utm_source=gopls) for the routing.
Communication between the Corteza server and web applications is done using the REST API and web sockets.
Communication between back-end services (Corteza server and Corredor) is done using gRPC.
The [Developer Guide/Corteza Server](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/corteza-server/index.html) covers the [development setup](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/corteza-server/index.html#_development_setup), the [project structure](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/corteza-server/structure.html), and the feature insight documents.
### Corteza Web Applications
The web applications are written in Vue.js and provide the user interface to interact with the entire system.
Communication between the Corteza server and web applications is done using the REST API and web sockets.
The [Developer Guide/Corteza Web Applications](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/web-applications/index.html) covers the [development setup](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/web-applications/index.html#_development_setup), the [project structure](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/web-applications/structure.html), and the feature insight documents.
### Documentation
The documentation is written in [AsciiDoc](https://asciidoc.org/) and compiled using [Antora](https://antora.org/).
The source code is available on the [GitHub cortezaproject/corteza-docs repository](https://github.com/cortezaproject/corteza-docs); the generated output is available on the [documentation page](http://docs.cortezaproject.org/).
The [Developer Guide/Documentation](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/documentation/index.html) covers the [conventions](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/documentation/index.html#_conventions), [writing guidelines](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/documentation/index.html#documentation-writing-guidelines), as well as some [examples](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/documentation/examples/index.html) to help you get started.
## Bug reporting
Please submit any bug reports on the **issues** section of the corresponding GitHub repository.
If you are unsure where to submit the issue, or you are unsure if this is a feature; reach out to us on [our forum](https://forum.cortezaproject.org/).
## Feature requests
Feature and improvement requests should be submitted on [our forum](https://forum.cortezaproject.org/).
Before opening a new topic, search around to see if there are any similar topics that would cover your case.
## DCO
By contributing to this project you agree to the Developer Certificate of Origin (DCO).
This document was created by the Linux Kernel community and is a simple statement that you, as a contributor, have the legal right to make the contribution.
See the [DCO](DCO) file for details.

36
DCO Normal file
View File

@@ -0,0 +1,36 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
660 York Street, Suite 102,
San Francisco, CA 94110 USA
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.

71
Dockerfile Normal file
View File

@@ -0,0 +1,71 @@
# build-stage
FROM alpine:3 as build-stage
# use docker build --build-arg VERSION=2021.9.0 .
ARG VERSION=2022.9.0
ARG SASS_VERSION=1.69.5
ARG SERVER_VERSION=${VERSION}
ARG WEBAPP_VERSION=${VERSION}
ARG CORTEZA_SERVER_PATH=https://releases.cortezaproject.org/files/corteza-server-${SERVER_VERSION}-linux-amd64.tar.gz
ARG CORTEZA_WEBAPP_PATH=https://releases.cortezaproject.org/files/corteza-webapp-${WEBAPP_VERSION}.tar.gz
ARG SASS_URL=https://github.com/sass/dart-sass/releases/download/${SASS_VERSION}/dart-sass-${SASS_VERSION}-linux-x64.tar.gz
RUN mkdir /tmp/server
RUN mkdir /tmp/webapp
ADD $CORTEZA_SERVER_PATH /tmp/server
ADD $CORTEZA_WEBAPP_PATH /tmp/webapp
RUN apk update && apk add --no-cache file
RUN apk add curl
RUN file "/tmp/server/$(basename $CORTEZA_SERVER_PATH)" | grep -q 'gzip' && \
tar zxvf "/tmp/server/$(basename $CORTEZA_SERVER_PATH)" -C / || \
cp -a "/tmp/server" /
RUN mv /corteza-server /corteza
WORKDIR /corteza
RUN rm -rf /corteza/webapp
RUN file "/tmp/webapp/$(basename $CORTEZA_WEBAPP_PATH)" | grep -q 'gzip' && \
mkdir /corteza/webapp && tar zxvf "/tmp/webapp/$(basename $CORTEZA_WEBAPP_PATH)" -C /corteza/webapp || \
cp -a "/tmp/webapp" /corteza/webapp
WORKDIR /tmp
RUN curl -sOL $SASS_URL
RUN tar -xzf dart-sass-${SASS_VERSION}-linux-x64.tar.gz
# deploy-stage
FROM ubuntu:22.04
RUN apt-get -y update \
&& apt-get -y install \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV STORAGE_PATH "/data"
ENV CORREDOR_ADDR "corredor:80"
ENV HTTP_ADDR "0.0.0.0:80"
ENV HTTP_WEBAPP_ENABLED "true"
ENV HTTP_WEBAPP_BASE_DIR "/corteza/webapp"
ENV PATH "/opt/dart-sass:/corteza/bin:${PATH}"
WORKDIR /corteza
VOLUME /data
COPY --from=build-stage /corteza ./
COPY --from=build-stage /tmp/dart-sass /opt/dart-sass
HEALTHCHECK --interval=30s --start-period=1m --timeout=30s --retries=3 \
CMD curl --silent --fail --fail-early http://127.0.0.1:80/healthcheck || exit 1
EXPOSE 80
ENTRYPOINT ["./bin/corteza-server"]
CMD ["serve-api"]

202
LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

36
Makefile Normal file
View File

@@ -0,0 +1,36 @@
.PHONY: dev test lint fresh audit
dev:
@echo "---Processing libs---"
@(cd $(CURDIR)/lib && make dev) || (echo "Failed to build libs"; exit 1)
@echo "---Processing clients---"
@(cd $(CURDIR)/client && make dev) || (echo "Failed to yarn clients"; exit 1)
test:
@echo "---Testing libs---"
@(cd $(CURDIR)/lib && make test) || (echo "Failed to test libs"; exit 1)
@echo "---Testing clients---"
@(cd $(CURDIR)/client && make test) || (echo "Failed to test clients"; exit 1)
@echo "---Testing server---"
@(cd $(CURDIR)/server && make test) || (echo "Failed to test server"; exit 1)
lint:
@echo "---Linting libs---"
@(cd $(CURDIR)/lib && make lint) || (echo "Failed to lint libs"; exit 1)
@echo "---Linting clients---"
@(cd $(CURDIR)/client && make lint) || (echo "Failed to lint clients"; exit 1)
fresh:
@echo "---Fresh---"
@(cd $(CURDIR)/lib && make fresh) || (echo "Failed to fresh libs"; exit 1)
@echo "---Fresh clients---"
@(cd $(CURDIR)/client && make fresh) || (echo "Failed to fresh clients"; exit 1)
audit:
@echo "---Audit---"
@(cd $(CURDIR)/lib && make audit) || true
@echo "---Audit clients---"
@(cd $(CURDIR)/client && make audit) || true
.DEFAULT_GOAL := dev

85
README.md Normal file
View File

@@ -0,0 +1,85 @@
<h1 align="center">
<img width="300px" src=".github/assets/corteza_logo.svg" />
<br />
<br />
<abbr>Freedom. Flexibility. Performance. Speed.</abbr>
<br />
<br />
<div align="center">
[![License Card](https://img.shields.io/github/license/cortezaproject/corteza?style=for-the-badge)](https://img.shields.io/github/license/cortezaproject/corteza?style=for-the-badge)
[![DockerHub Downloads Card](https://img.shields.io/docker/pulls/cortezaproject/corteza?style=for-the-badge)](https://img.shields.io/docker/pulls/cortezaproject/corteza?style=for-the-badge)
[![Latest Version Card](https://img.shields.io/github/v/release/cortezaproject/corteza?label=stable%20version&style=for-the-badge)](https://img.shields.io/github/v/release/cortezaproject/corteza?label=stable%20version&style=for-the-badge)
[![Go Report Card](https://goreportcard.com/badge/github.com/cortezaproject/corteza?style=for-the-badge)](https://goreportcard.com/report/github.com/cortezaproject/corteza?style=for-the-badge)
[![CodeCov Report Card](https://img.shields.io/codecov/c/github/cortezaproject/corteza-server?style=for-the-badge)](https://img.shields.io/codecov/c/github/cortezaproject/corteza-server?style=for-the-badge)
[![Community](https://img.shields.io/discourse/topics?server=https%3A%2F%2Fforum.cortezaproject.org%2F&style=for-the-badge)](https://img.shields.io/discourse/topics?server=https%3A%2F%2Fforum.cortezaproject.org%2F&style=for-the-badge)
</div>
</h1>
The Corteza low-code platform lets you build and iterate CRM, business process and other structured data apps fast, create intelligent business process workflows and connect with almost any data source.
All with the flexibility, freedom and control you get from a 100% open-source platform written on modern technologies maintained by [Planet Crust](https://www.planetcrust.com/), its founder.
<div align="center">
<img src=".github/assets/hero-animation.webp">
</div>
## Key Features
* **Standards oriented** by using well established data formats, technologies, and design decisions.
* **Flexible security** provided as a flattened RBAC facility allows organizations to apply complex internal security policies to Corteza.
* **Extensive privacy features** provide a way for organizations to configure Corteza to conform to data privacy regulations in the regions they are operating in.
* **Accessible** conforming to the WCAG 2.1 guidelines allowing simple use for a wider audience.
* **Integratable** into and with external services and other Corteza instances due to it's API-centric design, flexible data storage layer, and powerful automation facilities.
* **Customizable** from translations and terminology to prebuilt low-code applications to completely new low-code applications and business processes.
## Getting Started
<br />
> **Note**
> Planet Crust offers hosted [Corteza cloud instances](https://www.planetcrust.com/start-trial-lp-main) as the easiest way to get started.
<br />
Refer to the [DevOps guide](https://docs.cortezaproject.org/corteza-docs/2024.9/devops-guide/index.html) for a complete guide on how to get Corteza up and running.
Additionally, we've provided some [video instructions](https://forum.cortezaproject.org/t/videos-on-how-to-set-up-corteza/91).
Quick references:
* [data backup and restore](https://docs.cortezaproject.org/corteza-docs/2024.9/devops-guide/maintenance/backups.html)
* [troubleshooting](https://docs.cortezaproject.org/corteza-docs/2024.9/devops-guide/troubleshooting/index.html)
* [setting up an email relay](https://docs.cortezaproject.org/corteza-docs/2024.9/devops-guide/email-relay.html)
* [setting up sink routes](https://docs.cortezaproject.org/corteza-docs/2024.9/devops-guide/sink-route.html)
* [offline](https://docs.cortezaproject.org/corteza-docs/2024.9/devops-guide/examples/deploy-offline/index.html) and [online](https://docs.cortezaproject.org/corteza-docs/2024.9/devops-guide/examples/deploy-online/index.html) configuration examples
* [online demo instance](https://latest.cortezaproject.org/) for a hands-on feature exploration
## Creating With Corteza
Refer to the [Integrator Guide](https://docs.cortezaproject.org/corteza-docs/2024.9/integrator-guide/index.html) to learn how you can build on the core features to create virtually anything.
Quick references:
* [Corteza Compose configuration](https://docs.cortezaproject.org/corteza-docs/2024.9/integrator-guide/compose-configuration/index.html)
* automation using [workflows](https://docs.cortezaproject.org/corteza-docs/2024.9/integrator-guide/automation/workflows/index.html) and [automation scripts](https://docs.cortezaproject.org/corteza-docs/2024.9/integrator-guide/automation/automation-scripts/index.html)
* [using the REST API](https://docs.cortezaproject.org/corteza-docs/2024.9/integrator-guide/accessing-corteza/index.html),
## Upgrading Corteza
Refer to the [changelog](https://docs.cortezaproject.org/corteza-docs/2024.9/changelog/index.html) and the [upgrade guide](https://docs.cortezaproject.org/corteza-docs/2024.9/devops-guide/upgrade/index.html) to upgrade your Corteza instance.
## Contributing
Refer to the [Developer Guide/Corteza Server](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/corteza-server/index.html) document for details regarding the [development setup](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/corteza-server/index.html#_development_setup), the [project structure](https://docs.cortezaproject.org/corteza-docs/2024.9/developer-guide/corteza-server/structure.html), and the feature insight documents.
Refer to the [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and code of conduct.
## Community
Reach out to us on [our forum](https://forum.cortezaproject.org/).
## License
Corteza is released under the Apache-2.0 license.
Refer to the [LICENSE](LICENSE) file for additional information.

37
SECURITY.md Normal file
View File

@@ -0,0 +1,37 @@
# Security
At Corteza, the security of our products and services is important to us.
All of our source code repositories are managed through GitHub organisations.
Here is the list of [Corteza Repositories](https://github.com/orgs/cortezaproject/repositories)
If you believe you have found a security vulnerability in any Corteza repository, please report it to us as described below.
## Reporting Security Issues
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please send email to [info@cortezaproject.org](mailto:info@cortezaproject.org).
You should receive a response within 24 business hours. If for some reason you do not,
please follow up via email to ensure we received your original message.
Please include the requested information listed below (as much as you can provide)
to help us better understand the nature and scope of the possible issue:
* Type of issue,
* full paths of source file(s) related to the manifestation of the issue,
* the location of the affected source code (tag/branch/commit or direct URL),
* any special configuration required to reproduce the issue,
* step-by-step instructions to reproduce the issue,
* proof-of-concept or exploit code (if possible),
* impact of the issue, including how an attacker might exploit the issue.
This information will help us triage your report more quickly.
## Preferred Languages
We prefer all communications to be in English.
## Policy
[Corteza Privacy Policy](https://cortezaproject.org/privacy-policy/)

47
client/Makefile Normal file
View File

@@ -0,0 +1,47 @@
WEB_SUBDIRS := $(filter %/, $(wildcard $(CURDIR)/web/*/))
.PHONY: dev test lint fresh build audit
yarn:
@for dir in $(WEB_SUBDIRS); do \
echo "---Yarning $$dir---"; \
(cd $$dir && yarn) || (echo "Failed to yarn $$dir"; exit 1); \
done
dev:
@for dir in $(WEB_SUBDIRS); do \
echo "---Installing and linking clients $$dir---"; \
(cd $$dir && yarn && yarn cdeps) || (echo "Failed to run yarn or link libs $$dir"; exit 1); \
done
test:
@for dir in $(WEB_SUBDIRS); do \
echo "---Testing $$dir---"; \
(cd $$dir && yarn test) || (echo "Failed to test $$dir"; exit 1); \
done
lint:
@for dir in $(WEB_SUBDIRS); do \
echo "---Linting $$dir---"; \
(cd $$dir && yarn lint) || (echo "Failed to lint $$dir"; exit 1); \
done
fresh:
@for dir in $(WEB_SUBDIRS); do \
echo "---Fresh $$dir---"; \
(cd $$dir && rm -rf node_modules yarn.lock && yarn && yarn cdeps) || (echo "Failed to fresh $$dir"; exit 1); \
done
build:
@for dir in $(WEB_SUBDIRS); do \
echo "---Building $$dir---"; \
(cd $$dir && yarn build) || (echo "Failed to build $$dir"; exit 1); \
done
audit:
@for dir in $(WEB_SUBDIRS); do \
echo "---Auditing $$dir---"; \
(cd $$dir && yarn audit) || true; \
done
.DEFAULT_GOAL := dev

36
client/web/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# build-stage
FROM alpine:3 as build-stage
ARG VERSION=2022.9.0
ARG CORTEZA_CLIENT_WEB_AIO_PKG=https://releases.cortezaproject.org/files/corteza-webapp-${VERSION}.tar.gz
# Download package
ADD $CORTEZA_CLIENT_WEB_AIO_PKG /tmp
RUN apk update && apk add --no-cache file
RUN rm -rf /html; mkdir /html
WORKDIR /html
RUN file "/tmp/$(basename $CORTEZA_CLIENT_WEB_AIO_PKG)" | grep -q 'gzip' && \
tar zxvf "/tmp/$(basename $CORTEZA_CLIENT_WEB_AIO_PKG)" -C /html
# deploy-stage
FROM nginx:stable-alpine
WORKDIR /usr/share/nginx/html
COPY --from=build-stage /html /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
#COPY CONTRIBUTING.* DCO LICENSE README.* ./
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
HEALTHCHECK --interval=30s --start-period=10s --timeout=30s \
CMD wget --quiet --tries=1 --spider "http://127.0.0.1:80/config.js" || exit 1
ENTRYPOINT ["/entrypoint.sh"]

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>

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