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

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

View File

@@ -0,0 +1,150 @@
import Vue from 'vue'
import './config-check'
import './console-splash'
import './plugins'
import './mixins'
import './components'
import './filters'
import store from './store'
import { i18n, websocket } from '@cortezaproject/corteza-vue'
import router from './router'
export default (options = {}) => {
options = {
el: '#app',
name: 'privacy',
template: '<router-view v-if="loaded && i18nLoaded" />',
data: () => ({
loaded: false,
i18nLoaded: false,
}),
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
}
})
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)
}
// Load effective permissions
this.$store.dispatch('rbac/load')
// Initialize notifications
this.$store.dispatch('notifications/fetchNotifications')
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 messages
this.$on('websocket-message', ({ data }) => {
const msg = JSON.parse(data)
switch (msg['@type']) {
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-privacy' },
'dashboard',
'data-overview',
'general',
'map',
'module-records',
'navigation',
'notification',
'notifications',
'request',
'sensitive-data',
),
// 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,149 @@
<template>
<b-container
fluid
class="bg-white shadow-sm border-top p-3"
>
<b-row
no-gutters
class="align-items-center"
>
<b-col>
<b-button
v-if="backLink"
data-test-id="button-back"
variant="link"
size="lg"
:to="backLink"
class="d-flex align-items-center text-dark back text-decoration-none gap-1"
>
<font-awesome-icon
:icon="['fas', 'chevron-left']"
class="back-icon"
/>
{{ $t('general:label.back') }}
</b-button>
<slot name="left" />
</b-col>
<b-col
class="d-flex justify-content-center"
>
<slot name="middle" />
</b-col>
<b-col
class="d-flex justify-content-end"
>
<template v-if="deleteShow">
<c-input-confirm
v-if="deleteConfirm"
:disabled="deleteDisabled || processing"
:processing="processingDelete"
:text="deleteLabel"
variant="danger"
size="lg"
size-confirm="lg"
class="ml-1"
@confirmed="$emit('delete')"
/>
<b-button
v-else
:data-test-id="buttonLabelCypressId(deleteLabel)"
:disabled="deleteDisabled || processing"
variant="danger"
size="lg"
class="ml-1"
@click="$emit('delete')"
>
{{ deleteLabel }}
</b-button>
</template>
<c-button-submit
v-if="submitShow"
:data-test-id="buttonLabelCypressId(submitLabel)"
:disabled="submitDisabled"
:processing="processing"
:text="submitLabel"
size="lg"
class="ml-1"
@submit="$emit('submit')"
/>
<slot />
</b-col>
</b-row>
</b-container>
</template>
<script>
export default {
props: {
processing: {
type: Boolean,
},
processingDelete: {
type: Boolean,
},
backLink: {
type: Object,
default: () => ({ name: 'root' }),
},
deleteShow: {
type: Boolean,
},
deleteDisabled: {
type: Boolean,
},
deleteConfirm: {
type: Boolean,
default: true,
},
deleteLabel: {
type: String,
default: '',
},
submitShow: {
type: Boolean,
},
submitDisabled: {
type: Boolean,
},
submitLabel: {
type: String,
default: '',
},
},
methods: {
buttonLabelCypressId (label) {
return `button-${label.toLowerCase().split(' ').join('-')}`
},
},
}
</script>
<style lang="scss" scoped>
.back {
&:hover {
text-decoration: none;
.back-icon {
transition: transform 0.3s ease-out;
transform: translateX(-4px);
}
}
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<div
class="overflow-auto"
>
<b-card
v-for="m in modules"
:key="m.moduleID"
header-class="d-flex justify-content-between border-bottom"
class="border shadow-sm mb-3"
>
<template #header>
<b-form-group
:label="$t('module')"
label-class="text-primary"
class="mb-0"
>
{{ m.module }}
</b-form-group>
<b-form-group
:label="$t('namespace')"
label-class="text-primary"
class="mb-0"
>
{{ m.namespace }}
</b-form-group>
</template>
<h6
v-if="!m.records.length"
class="text-center"
>
{{ $t('no-records') }}
</h6>
<div
v-for="(r, ri) in m.records"
:key="r.recordID"
class="mb-0"
>
<b-form-group
label="RecordID"
label-class="text-primary"
>
{{ r.recordID }}
</b-form-group>
<b-row>
<b-col
v-for="value in r.values"
:key="value.name"
cols="12"
lg="6"
>
<b-form-group
:label="value.name"
label-class="text-primary"
>
<slot
:namespace="{ namespaceID: m.namespaceID, name: m.namespace }"
:module="{ moduleID: m.moduleID, name: m.module }"
:record="r"
:value="value"
/>
</b-form-group>
</b-col>
</b-row>
<hr
v-if="ri < m.records.length - 1"
>
</div>
</b-card>
</div>
</template>
<script>
export default {
i18nOptions: {
namespaces: 'module-records',
},
props: {
modules: {
type: Array,
required: true,
},
},
}
</script>

View File

@@ -0,0 +1,99 @@
<template>
<c-map
:map="{
zoom,
}"
hide-geo-search
hide-current-location-button
:markers="validMarkerValues"
style="min-height: 400px; height: 100% !important;"
>
<template #marker-tooltip="{ marker }">
<h5
class="text-primary"
>
{{ $t('server-details') }}
</h5>
<b-form-group
:label="$t('name')"
label-class="text-primary"
>
{{ marker.meta.name }}
</b-form-group>
<b-form-group
:label="$t('location')"
label-class="text-primary"
>
{{ getLocationName(marker) }}
</b-form-group>
</template>
</c-map>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CMap } = components
export default {
i18nOptions: {
namespaces: 'map',
},
components: {
CMap,
},
props: {
connections: {
type: Array,
required: true,
},
},
data () {
return {
zoom: 2,
}
},
computed: {
validMarkerValues () {
return this.connections
.filter(({ meta = {} }) => {
const { location = {} } = meta
const { geometry = {} } = location
const { coordinates = [] } = geometry
return coordinates && !!coordinates.length
})
.map((connection) => {
return {
id: connection.id,
value: this.getLocationCoordinates(connection),
...connection,
}
})
},
},
methods: {
getLocationCoordinates ({ meta = {} }) {
const { location = {} } = meta
const { geometry = {} } = location
return geometry.coordinates
},
getLocationName (connection) {
return connection.meta.location.properties.name || this.$t('unnamed-location')
},
},
}
</script>
<style lang="scss">
.vl-style-text {
color: var(--white);
}
</style>

View File

@@ -0,0 +1,182 @@
<template>
<b-card
header-class="border-bottom"
class="shadow-sm"
>
<template #header>
<h5 class="d-flex align-items-center justify-content-between mb-0">
{{ $t('label') }}
<b-dropdown
variant="link"
toggle-class="text-muted text-decoration-none"
:text="sort.includes('DESC') ? $t('sort.first.newest') : $t('sort.first.oldest')"
>
<b-dropdown-item-button
:disabled="sort.includes('DESC')"
@click="$emit('sort', 'createdAt DESC')"
>
{{ $t('sort.first.newest') }}
</b-dropdown-item-button>
<b-dropdown-item-button
:disabled="!sort.includes('DESC')"
@click="$emit('sort', 'createdAt')"
>
{{ $t('sort.first.oldest') }}
</b-dropdown-item-button>
</b-dropdown>
</h5>
</template>
<div
class="d-flex flex-column"
>
<b-form-textarea
id="textarea"
v-model="comment"
:placeholder="$t('enter')"
rows="2"
class="mb-2"
/>
<b-button
variant="primary"
:disabled="!comment"
class="ml-auto"
@click="submitComment()"
>
{{ $t('submit') }}
</b-button>
</div>
<hr v-if="comments.length || processing">
<div
v-if="processing"
class="d-flex align-items-center justify-content-center py-3"
>
<b-spinner />
</div>
<template v-else>
<div
v-for="(c, index) in comments"
:key="c.commentID"
:class="{ 'mt-3': index }"
class="overflow-auto"
>
<div class="d-flex align-items-center flex-wrap border p-2">
<h6 class="text-primary mb-0">
<b-spinner
v-if="formatting"
small
/>
<span v-else>
{{ formattedUsers[c.createdBy] || $t('unknown.user') }}
</span>
</h6>
<span class="ml-auto text-muted">
{{ formatDate(c.createdAt) }}
</span>
</div>
<div
class="border p-3"
>
<p
class="mb-0 multiline"
>
{{ c.comment }}
</p>
</div>
</div>
</template>
</b-card>
</template>
<script>
import { fmt, NoID } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'request',
keyPrefix: 'comments',
},
props: {
comments: {
type: Array,
required: true,
},
processing: {
type: Boolean,
required: true,
},
sort: {
type: String,
required: true,
},
},
data () {
return {
comment: '',
formatting: false,
formattedUsers: {},
}
},
watch: {
comments: {
immediate: true,
handler (comments) {
this.formatUsers(comments)
},
},
},
methods: {
submitComment () {
this.$emit('submit', this.comment)
this.comment = ''
},
formatDate (date) {
return date ? fmt.fullDateTime(date.toLocaleString()) : this.$t('unknown.date')
},
formatUsers (comments = []) {
const userID = []
comments.forEach(({ createdBy }) => {
if (createdBy !== NoID && !this.formattedUsers[createdBy]) {
userID.push(createdBy)
}
})
if (userID.length) {
this.formatting = true
this.$SystemAPI.userList({ userID, suspended: 1, deleted: 1 })
.then(({ set }) => {
set.forEach(({ userID, name, username, email, handle }) => {
this.$set(this.formattedUsers, userID, name || username || email || handle || userID || '')
})
})
.finally(() => {
this.formatting = false
})
}
},
},
}
</script>
<style lang="scss" scoped>
.multiline {
white-space: pre-line;
}
</style>

View File

@@ -0,0 +1,210 @@
<template>
<div
class="d-flex flex-column h-100"
>
<b-card
class="shadow-sm mb-4"
>
<b-form-group
:label="$t('connection.label')"
label-class="text-primary"
class="mb-0"
>
<c-input-select
v-model="connection"
:disabled="processing.connections"
:options="connections"
:clearable="false"
:get-option-label="({ handle, meta }) => meta.name || handle"
:get-option-key="getOptionKey"
:placeholder="$t('connection.placeholder')"
/>
</b-form-group>
</b-card>
<div
v-if="processing.sensitiveData"
class="d-flex align-items-center justify-content-center h-100"
>
<b-spinner />
</div>
<h5
v-else-if="!(connection && modules[connection.connectionID])"
class="text-center mt-5"
>
{{ $t('no-data-available') }}
</h5>
<module-records
v-else
v-slot="{ namespace, module, record, value }"
:modules="modules[connection.connectionID]"
>
<template v-if="value.value.length">
<b-form-input
v-for="(v, vi) in value.value"
:key="vi"
:value="v"
class="mb-1"
@update="updateValue({ namespace, module, recordID: record.recordID, field: value.name, value: $event, orgValue: v })"
/>
</template>
<b-form-input
v-else
:value="value.value[0]"
class="mb-1"
@update="updateValue({ namespace, module, recordID: record.recordID, field: value.name, value: $event, orgValue: '' })"
/>
</module-records>
<portal to="editor-toolbar">
<editor-toolbar
:processing="processing.connections || processing.sensitiveData"
:back-link="{ name: 'data-overview.application' }"
submit-show
:submit-label="$t('submit')"
:submit-disabled="!valid || !connection"
@submit="$emit('submit', { kind: 'correct', payload })"
/>
</portal>
</div>
</template>
<script>
import EditorToolbar from 'corteza-webapp-privacy/src/components/Common/EditorToolbar'
import ModuleRecords from 'corteza-webapp-privacy/src/components/Common/ModuleRecords'
export default {
i18nOptions: {
namespaces: 'request',
keyPrefix: 'edit.correct',
},
components: {
EditorToolbar,
ModuleRecords,
},
data () {
return {
processing: {
connections: true,
sensitiveData: true,
},
connection: undefined,
connections: [],
modules: {},
payload: {},
valid: false,
}
},
watch: {
connection: {
handler ({ connectionID } = {}) {
this.fetchSensitiveData(connectionID)
},
},
},
created () {
this.fetchConnections()
},
methods: {
fetchConnections () {
this.processing.connections = true
this.$SystemAPI.dataPrivacyConnectionList()
.then(({ set = [] }) => {
this.connections = set
if (!this.$route.params.connection) {
this.connection = set[0]
} else {
this.connection = this.$route.params.connection
}
})
.catch(this.toastErrorHandler(this.$t('notification:connection-load-failed')))
.finally(() => {
this.processing.connections = false
})
},
fetchSensitiveData (connectionID) {
if (connectionID) {
this.processing.sensitiveData = true
this.$ComposeAPI.dataPrivacyRecordList({ connectionID: [connectionID] })
.then(({ set = [] }) => {
if (set.length) {
this.$set(this.modules, connectionID, set)
}
// Reset payload
this.payload = {
connectionID,
modules: {},
}
})
.catch(this.toastErrorHandler(this.$t('notification:sensitive-data-fetch-failed')))
.finally(() => {
this.processing.sensitiveData = false
})
}
},
updateValue ({ namespace, module, recordID, field, value, orgValue }) {
const { connectionID } = this.connection
const { namespaceID, name: namespaceName } = namespace
const { moduleID, name: moduleName } = module
if (value === orgValue) {
delete this.payload.modules[moduleID].records[recordID].values[field]
if (Object.keys(this.payload.modules[moduleID].records[recordID].values).length === 0) {
delete this.payload.modules[moduleID].records[recordID]
if (Object.keys(this.payload.modules[moduleID].records).length === 0) {
delete this.payload.modules[moduleID]
}
}
this.valid = Object.keys(this.payload.modules).length > 0
return
}
if (connectionID) {
if (!this.payload.modules[moduleID]) {
this.payload.modules[moduleID] = {
namespace: namespaceName,
namespaceID,
module: moduleName,
moduleID,
records: {},
}
}
if (!this.payload.modules[moduleID].records[recordID]) {
this.payload.modules[moduleID].records[recordID] = {
values: {},
}
}
this.payload.modules[moduleID].records[recordID].values[field] = [value]
this.valid = true
}
},
getOptionKey ({ connectionID }) {
return connectionID
},
},
}
</script>

View File

@@ -0,0 +1,216 @@
<template>
<div
class="d-flex flex-column h-100"
>
<b-card
class="shadow-sm mb-3"
>
<b-form-group
:label="$t('connection.label')"
label-class="text-primary"
class="mb-0"
>
<c-input-select
v-model="connection"
:disabled="processing.connections"
:options="connections"
:clearable="false"
:get-option-label="({ handle, meta }) => meta.name || handle"
:get-option-key="getOptionKey"
:placeholder="$t('connection.placeholder')"
/>
</b-form-group>
</b-card>
<div
v-if="processing.sensitiveData"
class="d-flex align-items-center justify-content-center h-100"
>
<b-spinner />
</div>
<h5
v-else-if="!(connection && modules[connection.connectionID])"
class="text-center mt-5"
>
{{ $t('no-data-available') }}
</h5>
<module-records
v-else
v-slot="{ namespace, module, record, value }"
:modules="modules[connection.connectionID]"
>
<b-form-checkbox
v-if="value.value.length"
v-model="value.selected"
@change="updateValue({ namespace, module, recordID: record.recordID, field: value.name, value: $event, orgValue: value.value })"
>
<span
v-for="(v, vi) in value.value"
:key="vi"
class="py-2 mb-0"
>
<del
v-if="value.selected"
>
{{ v }}
</del>
<span
v-else
>
{{ v }}
</span>
</span>
</b-form-checkbox>
</module-records>
<portal to="editor-toolbar">
<editor-toolbar
:processing="processing.connections || processing.sensitiveData"
:back-link="{ name: 'data-overview.application' }"
submit-show
:submit-label="$t('submit')"
:submit-disabled="!valid || !connection"
@submit="$emit('submit', { kind: 'delete', payload })"
/>
</portal>
</div>
</template>
<script>
import EditorToolbar from 'corteza-webapp-privacy/src/components/Common/EditorToolbar'
import ModuleRecords from 'corteza-webapp-privacy/src/components/Common/ModuleRecords'
export default {
i18nOptions: {
namespaces: 'request',
keyPrefix: 'edit.delete',
},
components: {
EditorToolbar,
ModuleRecords,
},
data () {
return {
processing: {
connections: true,
sensitiveData: true,
},
connection: undefined,
connections: [],
modules: {},
payload: {},
valid: false,
}
},
watch: {
connection: {
handler ({ connectionID } = {}) {
this.fetchSensitiveData(connectionID)
},
},
},
created () {
this.fetchConnections()
},
methods: {
fetchConnections () {
this.processing.connections = true
this.$SystemAPI.dataPrivacyConnectionList()
.then(({ set = [] }) => {
this.connections = set
if (!this.$route.params.connection) {
this.connection = set[0]
} else {
this.connection = this.$route.params.connection
}
})
.catch(this.toastErrorHandler(this.$t('notification:connection-load-failed')))
.finally(() => {
this.processing.connections = false
})
},
fetchSensitiveData (connectionID) {
if (connectionID) {
this.processing.sensitiveData = true
this.$ComposeAPI.dataPrivacyRecordList({ connectionID: [connectionID] })
.then(({ set = [] }) => {
if (set.length) {
this.$set(this.modules, connectionID, set)
}
// Reset payload
this.payload = {
connectionID,
modules: {},
}
})
.catch(this.toastErrorHandler(this.$t('notification:sensitive-data-fetch-failed')))
.finally(() => {
this.processing.sensitiveData = false
})
}
},
updateValue ({ namespace, module, recordID, field, value, orgValue = [] }) {
const { connectionID } = this.connection
const { namespaceID, name: namespaceName } = namespace
const { moduleID, name: moduleName } = module
if (!value) {
delete this.payload.modules[moduleID].records[recordID].values[field]
if (Object.keys(this.payload.modules[moduleID].records[recordID].values).length === 0) {
delete this.payload.modules[moduleID].records[recordID]
if (Object.keys(this.payload.modules[moduleID].records).length === 0) {
delete this.payload.modules[moduleID]
}
}
this.valid = Object.keys(this.payload.modules).length > 0
return
}
if (connectionID) {
if (!this.payload.modules[moduleID]) {
this.payload.modules[moduleID] = {
namespace: namespaceName,
namespaceID,
module: moduleName,
moduleID,
records: {},
}
}
if (!this.payload.modules[moduleID].records[recordID]) {
this.payload.modules[moduleID].records[recordID] = {
values: {},
}
}
this.payload.modules[moduleID].records[recordID].values[field] = orgValue
this.valid = true
}
},
getOptionKey ({ connectionID }) {
return connectionID
},
},
}
</script>

View File

@@ -0,0 +1,111 @@
<template>
<div
class="d-flex flex-column h-100"
>
<b-card
class="shadow-sm mb-3"
>
<b-form-group
:label="$t('data-type.label')"
label-class="text-primary"
>
<b-form-checkbox
v-model="payload.profile"
data-test-id="checkbox-profile-information"
class="ml-2 mb-1"
>
{{ $t('data-type.profile-information') }}
</b-form-checkbox>
<b-form-checkbox
v-model="payload.application"
data-test-id="checkbox-application-data"
class="ml-2"
>
{{ $t('data-type.application-data') }}
</b-form-checkbox>
</b-form-group>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('date-range.label')"
label-class="text-primary"
>
<b-form-select
v-model="payload.range"
:options="rangeOptions"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('file-format.label')"
label-class="text-primary"
>
<b-form-select
v-model="payload.format"
data-test-id="select-file-format"
:options="formatOptions"
/>
</b-form-group>
</b-col>
</b-row>
</b-card>
<portal to="editor-toolbar">
<editor-toolbar
:processing="processing"
:back-link="{ name: 'data-overview.application' }"
submit-show
:submit-label="$t('submit')"
:submit-disabled="!(payload.profile || payload.application)"
@submit="$emit('submit', { kind: 'export', payload })"
/>
</portal>
</div>
</template>
<script>
import EditorToolbar from 'corteza-webapp-privacy/src/components/Common/EditorToolbar'
export default {
name: 'ApplicationDataDelete',
i18nOptions: {
namespaces: 'request',
keyPrefix: 'edit.export',
},
components: {
EditorToolbar,
},
data () {
return {
processing: false,
payload: {
profile: false,
application: false,
range: 'all',
format: 'json',
},
rangeOptions: [
{ text: this.$t('date-range.all'), value: 'all' },
],
formatOptions: [
{ text: this.$t('file-format.json'), value: 'json' },
{ text: this.$t('file-format.csv'), value: 'csv' },
],
}
},
}
</script>

View File

@@ -0,0 +1,40 @@
<template>
<div
class="d-flex flex-column h-100"
>
<portal to="topbar-title">
{{ $t(`request:kind.${kind}`) }}
</portal>
<component
:is="kind"
v-on="$listeners"
/>
</div>
</template>
<script>
import Correct from './Correct'
import Delete from './Delete'
import Export from './Export'
export default {
i18nOptions: {
namespaces: 'request',
keyPrefix: 'edit',
},
components: {
Correct,
Delete,
Export,
},
props: {
kind: {
type: String,
required: true,
},
},
}
</script>

View File

@@ -0,0 +1,49 @@
<template>
<module-records
v-slot="{ value }"
:modules="payloadValues"
>
<p
v-for="(v, vi) in value.value"
:key="vi"
class="mb-0"
:class="{ 'mt-1': vi > 0 }"
>
{{ v }}
</p>
</module-records>
</template>
<script>
import base from './base'
import ModuleRecords from 'corteza-webapp-privacy/src/components/Common/ModuleRecords'
export default {
i18nOptions: {
namespaces: 'request',
keyPrefix: 'view.correct',
},
components: {
ModuleRecords,
},
extends: base,
computed: {
payloadValues () {
const { modules = {} } = this.payload || {}
return Object.entries(modules).map(([moduleID, { module, namespace, records = {} }]) => {
records = Object.entries(records).map(([recordID, { values = {} }]) => {
values = Object.entries(values).map(([name, value = []]) => {
return { name, value }
})
return { recordID, values }
})
return { module, namespace, moduleID, records }
})
},
},
}
</script>

View File

@@ -0,0 +1,49 @@
<template>
<module-records
v-slot="{ value }"
:modules="payloadValues"
>
<p
v-for="(v, vi) in value.value"
:key="vi"
class="mb-0"
:class="{ 'mt-1': vi > 0 }"
>
{{ v }}
</p>
</module-records>
</template>
<script>
import base from './base'
import ModuleRecords from 'corteza-webapp-privacy/src/components/Common/ModuleRecords'
export default {
i18nOptions: {
namespaces: 'request',
keyPrefix: 'view.delete',
},
components: {
ModuleRecords,
},
extends: base,
computed: {
payloadValues () {
const { modules = {} } = this.payload || {}
return Object.entries(modules).map(([moduleID, { module, namespace, records = {} }]) => {
records = Object.entries(records).map(([recordID, { values = {} }]) => {
values = Object.entries(values).map(([name, value = []]) => {
return { name, value }
})
return { recordID, values }
})
return { module, namespace, moduleID, records }
})
},
},
}
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div
class="p-3"
>
<b-form-group
:label="$t('data-type.label')"
label-class="text-primary"
>
<span
data-test-id="span-data-type"
class="ml-2"
>
{{ dataType }}
</span>
</b-form-group>
<b-form-group
:label="$t('date-range.label')"
label-class="text-primary"
>
<span
data-test-id="span-date-range"
class="ml-2"
>
{{ $t(`date-range.${payload.range}`) }}
</span>
</b-form-group>
<b-form-group
:label="$t('file-format.label')"
label-class="text-primary"
>
<span
data-test-id="span-file-format"
class="ml-2"
>
{{ $t(`file-format.${payload.format}`) }}
</span>
</b-form-group>
</div>
</template>
<script>
import base from './base'
export default {
extends: base,
i18nOptions: {
namespaces: 'request',
keyPrefix: 'view.export',
},
computed: {
dataType () {
const { profile = false, application = false } = this.payload
return [
{ label: this.$t('data-type.profile-information'), include: profile },
{ label: this.$t('data-type.application-data'), include: application },
].filter(({ include }) => include)
.map(({ label }) => label)
.join(', ')
},
},
}
</script>

View File

@@ -0,0 +1,17 @@
<script>
export default {
props: {
request: {
type: Object,
required: true,
},
},
computed: {
payload () {
const { payload = [] } = this.request || {}
return payload[0]
},
},
}
</script>

View File

@@ -0,0 +1,111 @@
<template>
<b-card
v-if="!processing"
header-class="border-bottom"
body-class="p-2"
class="shadow-sm"
>
<portal to="topbar-title">
{{ $t(`request:kind.${request.kind}`) }}
</portal>
<template #header>
<h5
class="d-flex align-items-center justify-content-between"
>
<span>
{{ formattedDate }}
</span>
<b-badge
:data-test-id="`badge-${request.status}`"
:variant="statusVariants[request.status]"
pill
class="px-2 py-1"
>
{{ $t(`request:status.${request.status}`) }}
</b-badge>
</h5>
<b-card-text
data-test-id="request-author"
class="text-primary"
>
{{ formattedUsers[request.requestedBy] || 'Unknown user' }}
</b-card-text>
</template>
<component
:is="request.kind"
:request="request"
/>
</b-card>
</template>
<script>
import { fmt, NoID } from '@cortezaproject/corteza-js'
import base from './base'
import Correct from './Correct'
import Delete from './Delete'
import Export from './Export'
export default {
i18nOptions: {
namespaces: 'request',
keyPrefix: 'view',
},
components: {
Correct,
Delete,
Export,
},
extends: base,
data () {
return {
processing: false,
formattedUsers: {},
statusVariants: {
canceled: 'secondary',
pending: 'warning',
rejected: 'danger',
approved: 'success',
},
}
},
computed: {
formattedDate () {
return this.request ? fmt.fullDateTime(this.request.requestedAt.toLocaleString()) : 'Unknown date'
},
},
watch: {
'request.requestedBy': {
immediate: true,
handler (userID) {
if (userID === this.$auth.user.userID) {
const { name, username, email, handle } = this.$auth.user
this.formattedUsers[userID] = name || username || email || handle || userID || ''
return
}
if (userID !== NoID && !this.formattedUsers[userID]) {
this.processing = true
this.$SystemAPI.userRead({ userID })
.then(({ name, username, email, handle }) => {
this.formattedUsers[userID] = name || username || email || handle || userID || ''
})
.finally(() => {
this.processing = false
})
}
},
},
},
}
</script>

View File

@@ -0,0 +1,59 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faGripHorizontal,
faThumbtack,
faHome,
faChevronLeft,
faChevronRight,
faThLarge,
faCheck,
faCheckDouble,
faTimes,
faAngleUp,
faAngleDown,
faAngleLeft,
faAngleRight,
faAngleDoubleLeft,
faAngleDoubleRight,
faSearch,
faBellSlash,
faBell as faBellSolid,
faEllipsisV,
} from '@fortawesome/free-solid-svg-icons'
import {
faUser,
faQuestionCircle,
faBell,
faTrashAlt,
faEnvelope,
faEnvelopeOpen,
} from '@fortawesome/free-regular-svg-icons'
library.add(
faHome,
faThLarge,
faUser,
faThumbtack,
faQuestionCircle,
faChevronLeft,
faChevronRight,
faGripHorizontal,
faCheck,
faCheckDouble,
faTimes,
faAngleLeft,
faAngleRight,
faAngleDoubleLeft,
faAngleDoubleRight,
faSearch,
faAngleUp,
faAngleDown,
faBell,
faBellSolid,
faBellSlash,
faEllipsisV,
faTrashAlt,
faEnvelope,
faEnvelopeOpen,
)

View File

@@ -0,0 +1,13 @@
import Vue from 'vue'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import PortalVue from 'portal-vue'
import './faIcons'
import { components } from '@cortezaproject/corteza-vue'
Vue.use(PortalVue)
Vue.component('font-awesome-icon', FontAwesomeIcon)
Vue.component('font-awesome-layers', FontAwesomeLayers)
Vue.component('c-permissions-button', components.CPermissionsButton)
Vue.component('c-input-confirm', components.CInputConfirm)
Vue.component('c-button-submit', components.CButtonSubmit)
Vue.component('c-input-select', components.CInputSelect)

View File

@@ -0,0 +1,8 @@
[
'CortezaAPI',
].forEach((cfg) => {
if (window[cfg] === undefined) {
throw new Error(`Missing or invalid configuration.
Make sure there is a public/config.js configuration file with window.${cfg} entry.`)
}
})

View File

@@ -0,0 +1,6 @@
/* eslint-disable no-undef */
const text = `%c${WEBAPP || 'Corteza Webapp'}, version: ${VERSION}, build time: ${BUILD_TIME}`
const style = 'background-color: #1397CB; color: white; padding: 3px 10px; border: 1px solid black; font: Courier'
/* eslint-disable no-console */
console.log(text, style)

View File

@@ -0,0 +1,33 @@
import Vue from 'vue'
import Router from 'vue-router'
import BootstrapVue from 'bootstrap-vue'
import c3catalogue from './components/C3'
import { components, i18n } from '@cortezaproject/corteza-vue'
import './components'
import './themes'
const routes = [
{
path: '/c3',
name: 'c3',
component: components.C3.View,
props: { catalogue: c3catalogue },
},
{ path: '*', redirect: { name: 'c3' } },
]
Vue.use(Router)
Vue.use(BootstrapVue)
export default new Vue({
el: '#app',
name: 'DevEnv',
template: '<router-view/>',
router: new Router({
mode: 'history',
routes,
}),
i18n: i18n(Vue,
{ app: 'corteza-webapp-privacy' },
),
})

View File

@@ -0,0 +1,6 @@
import Vue from 'vue'
import { filters } from '@cortezaproject/corteza-vue'
for (const n in filters) {
Vue.filter(n, filters[n])
}

View File

@@ -0,0 +1,4 @@
import app from './app'
import './themes'
app()

View File

@@ -0,0 +1,5 @@
import Vue from 'vue'
import { mixins } from '@cortezaproject/corteza-vue'
Vue.mixin(mixins.toast)

View File

@@ -0,0 +1,223 @@
import axios from 'axios'
import { mapActions } from 'vuex'
export default {
data () {
return {
/**
* Placeholder, if component does not define own filter
*/
filter: {},
pagination: {
limit: 10,
pageCursor: undefined,
prevPage: '',
nextPage: '',
total: 0,
page: 1,
},
// Used to save query when fetching for total if we came to the page with a pageCursor in URL
tempQuery: undefined,
sorting: {},
abortableRequests: [],
cancelled: false,
}
},
watch: {
/**
* When fullPath for this component changes, we most likely should update our
* filters.
* @todo make this.filter reactive
*/
'$route.fullPath': {
handler () {
this.handleQueryParams()
},
},
},
created () {
this.handleQueryParams(true)
},
beforeDestroy () {
this.abortRequests()
},
methods: {
...mapActions({
incLoader: 'ui/incLoader',
decLoader: 'ui/decLoader',
}),
/**
* Parses query params into list filtering params.
* @param initial {Boolean} - used to determine it this is the initial fetch
*/
handleQueryParams (initial = false) {
// Pagination
let {
limit = this.pagination.limit,
pageCursor = this.pagination.pageCursor,
prevPage = this.pagination.prevCursor,
nextPage = this.pagination.nextCursor,
total = this.pagination.total,
page = this.pagination.page,
...r1
} = this.$route.query
limit = parseInt(limit)
total = parseInt(total)
page = parseInt(page)
// If we came to the page with a pageCursor in the URL
if (initial && pageCursor) {
this.tempQuery = this.$route.query
// Fetch replace query to trigger fetch of total number of items
this.$router.replace({ query: { ...this.$route.query, limit: 1, pageCursor: undefined } })
return
}
/// To prevent extra list fetch, check if pageCursor is defined (not first page)
const refresh = this.$route.query.pageCursor !== this.pagination.pageCursor
this.pagination = { limit, pageCursor, prevPage, nextPage, total, page }
// Sorting
let { sortBy = this.sorting.sortBy, sortDesc = this.sorting.sortDesc, ...r2 } = r1
sortDesc = sortDesc === true || sortDesc === 'true'
// Reset pageCursor when sort changes, except on first fetch (so we use the pageCursor from url)
if (!initial && (sortBy !== this.sorting.sortBy || sortDesc !== this.sorting.sortDesc)) {
this.pagination.pageCursor = ''
this.pagination.page = 1
}
this.sorting = { sortBy, sortDesc }
// Filtering
// make sure filter fields are of the right type
for (const key in r2) {
if (typeof this.filter[key] === 'boolean') {
r2[key] = r2[key] === 'true'
}
}
this.filter = { ...this.filter, ...r2 }
// Only refresh if pageCursor actually changed,
if (refresh) {
this.$root.$emit('bv::refresh::table', 'resource-list')
}
},
filterList () {
// reset pagination when filtering changes
//
// we want to prevent situations with page is preset to a number that
// exceeds the number of pages of a filtered results
this.pagination.pageCursor = ''
this.pagination.page = 1
// notify b-table about the change
//
// this effectively calls items()/procListResults()
this.abortRequests()
this.$root.$emit('bv::refresh::table', 'resource-list')
},
encodeListParams () {
const { sortBy, sortDesc } = this.sorting
const { limit, pageCursor } = this.pagination
const sort = sortBy ? `${sortBy} ${sortDesc ? 'DESC' : 'ASC'}` : undefined
return {
limit,
sort: pageCursor ? undefined : sort,
...this.filter,
pageCursor,
incTotal: !pageCursor || this.tempQuery,
}
},
encodeRouteParams () {
const { limit, pageCursor, page } = this.pagination
return {
query: {
limit,
...this.sorting,
...this.filter,
page,
pageCursor,
},
}
},
/**
*
* @param p {Promise}
* @returns {Promise}
*/
procListResults (p, updateQuery = true) {
this.abortRequests()
const { response, cancel } = p
this.abortableRequests.push(cancel)
// Push new router/params to cause URL change
//
// We want this because in case when user refreshes or shares URL
// he needs to land on the same page with the same parameters
if (updateQuery && !this.tempQuery) {
this.$router.replace(this.encodeRouteParams())
}
return Promise.all([response(), new Promise(resolve => setTimeout(resolve, 300))])
.then(async ([{ set, filter }]) => {
if (filter.incTotal) {
this.pagination.total = filter.total
}
// This was a fetch of total number of items
if (this.tempQuery) {
const query = this.tempQuery
this.tempQuery = undefined
this.$router.replace({ query })
return []
}
this.pagination.pageCursor = undefined
this.pagination.nextPage = filter.nextPage
this.pagination.prevPage = filter.prevPage
return set
}).catch(error => {
if (!axios.isCancel(error)) {
this.toastErrorHandler(this.$t('notification:list.load.error'))(error)
} else {
this.cancelled = true
}
}).finally(() => {
this.cancelled = false
})
},
genericRowClass (item) {
return { 'text-secondary': item && !!item.deletedAt }
},
abortRequests () {
this.abortableRequests.forEach((cancel) => {
cancel()
})
this.abortableRequests = []
},
},
}

View File

@@ -0,0 +1,28 @@
import Vue from 'vue'
import Router from 'vue-router'
import { BootstrapVue, BootstrapVueIcons } from 'bootstrap-vue'
import VueNativeSock from 'vue-native-websocket'
import { plugins, websocket } from '@cortezaproject/corteza-vue'
Vue.use(Router)
Vue.use(BootstrapVue, {
BToast: {
// see https://bootstrap-vue.org/docs/components/toast#comp-ref-b-toast-props
autoHideDelay: 7000,
toaster: 'b-toaster-bottom-right',
},
BModal: {
noEnforceFocus: true,
},
})
Vue.use(BootstrapVueIcons)
Vue.use(plugins.Auth(), { app: 'privacy' })
Vue.use(plugins.CortezaAPI('system'))
Vue.use(plugins.CortezaAPI('compose'))
Vue.use(plugins.Settings, { api: Vue.prototype.$SystemAPI })
Vue.use(VueNativeSock, websocket.endpoint(), websocket.config)

View File

@@ -0,0 +1,7 @@
import Router from 'vue-router'
import routes from './views/routes.js'
export default new Router({
mode: 'history',
routes,
})

View File

@@ -0,0 +1,23 @@
import Vue from 'vue'
import Vuex from 'vuex'
import { store as cvStore } from '@cortezaproject/corteza-vue'
Vue.use(Vuex)
const store = new Vuex.Store({
modules: {
rbac: {
namespaced: true,
...cvStore.RBAC(Vue.prototype.$SystemAPI),
},
notifications: {
namespaced: true,
...cvStore.notifications({
api: Vue.prototype.$SystemAPI,
}),
},
},
})
export default store

View File

@@ -0,0 +1,8 @@
.card-hover-popup {
transition: all 0.1s ease-in-out;
&:hover {
box-shadow: 0 4px 8px rgba(38, 38, 38, 0.2);
transform: translate3d(0, -2px, 0);
}
}

View File

@@ -0,0 +1 @@
import './index.scss'

View File

@@ -0,0 +1,2 @@
@import "./custom";
@import "./poppins.scss";

View File

@@ -0,0 +1,146 @@
// Fonts Path
$fonts_dir : './fonts/' !default;
@font-face {
font-family: 'Poppins-Bold';
src: url($fonts_dir + 'poppins/Poppins-Bold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-BlackItalic';
src: url($fonts_dir + 'poppins/Poppins-BlackItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Italic';
src: url($fonts_dir + 'poppins/Poppins-Italic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-BoldItalic';
src: url($fonts_dir + 'poppins/Poppins-BoldItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraBoldItalic';
src: url($fonts_dir + 'poppins/Poppins-ExtraBoldItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraLightItalic';
src: url($fonts_dir + 'poppins/Poppins-ExtraLightItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Black';
src: url($fonts_dir + 'poppins/Poppins-Black.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraBold';
src: url($fonts_dir + 'poppins/Poppins-ExtraBold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraLight';
src: url($fonts_dir + 'poppins/Poppins-ExtraLight.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-MediumItalic';
src: url($fonts_dir + 'poppins/Poppins-MediumItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-SemiBoldItalic';
src: url($fonts_dir + 'poppins/Poppins-SemiBoldItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-SemiBold';
src: url($fonts_dir + 'poppins/Poppins-SemiBold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-LightItalic';
src: url($fonts_dir + 'poppins/Poppins-LightItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Regular';
src: url($fonts_dir + 'poppins/Poppins-Regular.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Medium';
src: url($fonts_dir + 'poppins/Poppins-Medium.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Light';
src: url($fonts_dir + 'poppins/Poppins-Light.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ThinItalic';
src: url($fonts_dir + 'poppins/Poppins-ThinItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Thin';
src: url($fonts_dir + 'poppins/Poppins-Thin.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line no-unused-expressions
import(/* webpackChunkName: 'corteza-base' */ './corteza-base')

View File

@@ -0,0 +1,142 @@
<template>
<div class="d-flex flex-column w-100 h-viewport">
<header>
<c-topbar
:expanded="expanded"
:settings="$Settings.get('ui.topbar', {})"
:labels="{
appMenu: $t('navigation:appMenu'),
helpForum: $t('navigation:help.forum'),
helpDocumentation: $t('navigation:help.documentation'),
helpFeedback: $t('navigation:help.feedback'),
helpVersion: $t('navigation:help.version'),
userSettingsLoggedInAs: $t('navigation:userSettings.loggedInAs', { user }),
userSettingsProfile: $t('navigation:userSettings.profile'),
userSettingsChangePassword: $t('navigation:userSettings.changePassword'),
userSettingsLogout: $t('navigation:userSettings.logout'),
userSettingsTheme: $t('navigation:userSettings.theme'),
lightTheme: $t('general:themes.labels.light'),
darkTheme: $t('general:themes.labels.dark'),
}"
>
<template #title>
<portal-target
name="topbar-title"
/>
</template>
<template #tools>
<portal-target
name="topbar-tools"
/>
</template>
</c-topbar>
</header>
<aside>
<c-sidebar
:expanded.sync="expanded"
:icon="icon"
:logo="logo"
:disabled-routes="['dashboard']"
>
<template #header-expanded>
<portal-target name="sidebar-header-expanded" />
</template>
<template #body-expanded>
<portal-target name="sidebar-body-expanded" />
</template>
<template #footer-expanded>
<portal-target name="sidebar-footer-expanded" />
</template>
</c-sidebar>
</aside>
<main class="d-inline-flex h-100 overflow-auto">
<!--
Content spacer
Large and xl screens should push in content when the nav is expanded
-->
<template>
<div
class="sidebar-spacer d-print-none"
:class="{
'expanded': expanded,
}"
/>
</template>
<div
class="d-flex flex-column w-100"
>
<router-view
class="flex-grow-1 overflow-auto"
/>
<portal-target
name="editor-toolbar"
/>
</div>
</main>
<c-extend-session
v-if="isAutoLogoutEnabled"
:timeout="$Settings.get('auth.autoLogout.timeout')"
:labels="{
extend: $t('general:extendSession.labels.extend'),
warning: (countdownTime) => $t('general:extendSession.labels.warning', { countdownTime }),
}"
/>
<c-notification-sidebar v-if="!$Settings.get('ui.topbar', {}).hideNotifications" />
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CTopbar, CSidebar, CExtendSession, CNotificationSidebar } = components
export default {
name: 'Layout',
components: {
CTopbar,
CSidebar,
CExtendSession,
CNotificationSidebar,
},
data () {
return {
expanded: false,
}
},
computed: {
user () {
const { user } = this.$auth
return user.name || user.handle || user.email || ''
},
icon () {
return this.$Settings.attachment('ui.iconLogo')
},
logo () {
return this.$Settings.attachment('ui.mainLogo')
},
isAutoLogoutEnabled () {
return this.$Settings.get('auth.autoLogout.enabled')
},
},
}
</script>
<style scoped>
.h-viewport {
height: 100vh;
height: 100dvh;
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<b-container
v-if="isDC !== null"
fluid
class="d-flex flex-column p-3"
>
<portal to="topbar-title">
{{ $t('title') }}
</portal>
<div
class="flex-shrink-1"
>
<p>
{{ $t('description.first') }}<br>
{{ $t('description.second') }}
</p>
<b-row
align-v="stretch"
>
<b-col
v-for="option in options"
:key="option.title"
cols="12"
md="6"
xl="3"
class="mb-3"
>
<b-card
:title="option.title"
class="card-hover-popup shadow-sm h-100"
body-class="d-flex flex-column"
>
<b-card-text class="flex-grow-1">
{{ option.description }}
</b-card-text>
<b-button
:variant="option.button.variant || 'light'"
:to="option.button.to"
>
{{ option.button.label }}
</b-button>
</b-card>
</b-col>
</b-row>
</div>
<div
class="d-flex flex-column h-100"
>
<h6
class="text-primary"
>
{{ $t('connection-location') }}
</h6>
<connection-map
:connections="connections"
style="min-height: 400px;"
class="rounded-lg shadow-lg"
/>
</div>
</b-container>
</template>
<script>
import ConnectionMap from 'corteza-webapp-privacy/src/components/ConnectionMap'
export default {
name: 'Dashboard',
i18nOptions: {
namespaces: 'dashboard',
},
components: {
ConnectionMap,
},
data () {
return {
processing: false,
isDC: null,
connections: [],
userOptions: [
{
title: this.$t('user-options.data-overview.title'),
description: this.$t('user-options.data-overview.description'),
button: { label: this.$t('user-options.data-overview.button-label'), to: { name: 'data-overview' } },
},
{
title: this.$t('user-options.privacy-requests.title'),
description: this.$t('user-options.privacy-requests.description'),
button: { label: this.$t('user-options.privacy-requests.button-label'), to: { name: 'request.list' } },
},
{
title: this.$t('user-options.export.title'),
description: this.$t('user-options.export.description'),
button: { label: this.$t('user-options.export.button-label'), to: { name: 'request.create', params: { kind: 'export' } } },
},
{
title: this.$t('user-options.delete.title'),
description: this.$t('user-options.delete.description'),
button: { label: this.$t('user-options.delete.button-label'), variant: 'danger', to: { name: 'request.create', params: { kind: 'delete' } } },
},
],
dcOptions: [
{
title: this.$t('dc-options.privacy-requests.title'),
description: this.$t('dc-options.privacy-requests.description'),
button: { label: this.$t('dc-options.privacy-requests.button-label'), to: { name: 'request.list' } },
},
{
title: this.$t('dc-options.sensitive-data.title'),
description: this.$t('dc-options.sensitive-data.description'),
button: { label: this.$t('dc-options.sensitive-data.button-label'), to: { name: 'sensitive-data' } },
},
],
}
},
computed: {
options () {
return this.isDC ? this.dcOptions : this.userOptions
},
},
created () {
this.fetchConnections()
this.checkIsDC()
},
methods: {
fetchConnections () {
this.processing = true
this.$SystemAPI.dataPrivacyConnectionList()
.then(({ set = [] }) => {
this.connections = set
})
.catch(this.toastErrorHandler(this.$t('notification:connection-load-failed')))
.finally(() => {
this.processing = false
})
},
checkIsDC () {
this.$SystemAPI.roleList({ query: 'data-privacy-officer', memberID: this.$auth.user.userID })
.then(({ set = [] }) => {
this.isDC = !!set.length
})
},
},
}
</script>

View File

@@ -0,0 +1,178 @@
<template>
<b-container
class="d-flex flex-column p-3"
>
<portal to="topbar-title">
{{ $t('title') }}
</portal>
<b-card
class="shadow-sm mb-4"
>
<b-form-group
:label="$t('connection.label')"
label-class="text-primary"
class="mb-0"
>
<c-input-select
v-model="connectionID"
:disabled="processing.connections"
:options="connections"
:clearable="false"
:reduce="o => o.connectionID"
:placeholder="$t('connection.placeholder')"
:get-option-label="({ handle, meta }) => meta.name || handle"
:get-option-key="getOptionKey"
/>
</b-form-group>
</b-card>
<div
v-if="processing.sensitiveData"
class="d-flex align-items-center justify-content-center h-100"
>
<b-spinner />
</div>
<h5
v-else-if="!(connectionID && modules[connectionID])"
class="text-center mt-5"
>
{{ $t('no-data-available') }}
</h5>
<module-records
v-else
v-slot="{ value }"
:modules="modules[connectionID]"
>
<p
v-for="(v, vi) in value.value"
:key="vi"
class="mb-0"
:class="{ 'mt-1': vi > 0 }"
>
{{ v }}
</p>
</module-records>
<portal to="editor-toolbar">
<editor-toolbar
:processing="processing.connections || processing.sensitiveData"
:back-link="{ name: 'data-overview' }"
>
<b-button
data-test-id="button-request-deletion"
:disabled="processing.connections || processing.sensitiveData"
variant="light"
size="lg"
class="ml-1"
@click="$router.push({ name: 'request.create', params: { kind: 'delete', connection } })"
>
{{ $t('request-deletion') }}
</b-button>
<b-button
data-test-id="button-request-correction"
:disabled="processing.connections || processing.sensitiveData"
variant="primary"
size="lg"
class="ml-1"
@click="$router.push({ name: 'request.create', params: { kind: 'correct', connection } })"
>
{{ $t('request-correction') }}
</b-button>
</editor-toolbar>
</portal>
</b-container>
</template>
<script>
import EditorToolbar from 'corteza-webapp-privacy/src/components/Common/EditorToolbar'
import ModuleRecords from 'corteza-webapp-privacy/src/components/Common/ModuleRecords'
export default {
name: 'ApplicationDataOverview',
i18nOptions: {
namespaces: 'data-overview',
keyPrefix: 'application',
},
components: {
EditorToolbar,
ModuleRecords,
},
data () {
return {
processing: {
connections: true,
sensitiveData: true,
},
connectionID: undefined,
connections: [],
modules: {},
}
},
computed: {
connection () {
return this.connections.find(({ connectionID }) => connectionID === this.connectionID) || {}
},
},
watch: {
connectionID: {
handler (connectionID = '') {
this.fetchSensitiveData(connectionID)
},
},
},
created () {
this.fetchConnections()
},
methods: {
fetchConnections () {
this.processing.connections = true
this.$SystemAPI.dataPrivacyConnectionList()
.then(({ set = [] }) => {
this.connections = set
const { connectionID } = set[0] || {}
this.connectionID = connectionID
})
.catch(this.toastErrorHandler(this.$t('notification:connection-load-failed')))
.finally(() => {
this.processing.connections = false
})
},
fetchSensitiveData (connectionID) {
if (connectionID) {
this.processing.sensitiveData = true
this.$ComposeAPI.dataPrivacyRecordList({ connectionID: [connectionID] })
.then(({ set = [] }) => {
if (set.length) {
this.$set(this.modules, connectionID, set)
}
})
.catch(this.toastErrorHandler(this.$t('notification:sensitive-data-fetch-failed')))
.finally(() => {
this.processing.sensitiveData = false
})
}
},
getOptionKey ({ connectionID }) {
return connectionID
},
},
}
</script>

View File

@@ -0,0 +1,106 @@
<template>
<b-container
fluid
class="d-flex flex-column p-3"
>
<portal to="topbar-title">
{{ $t('title') }}
</portal>
<b-row
v-for="type in dataTypes"
:key="type.title"
>
<b-col
cols="12"
lg="6"
>
<b-row>
<b-col
class="mb-3"
>
<b-card
no-body
class="card-hover-popup shadow-sm pointer"
>
<b-row no-gutters>
<b-col
cols="2"
align-self="center"
class="p-2 text-center"
>
<font-awesome-icon
:icon="type.icon"
class="text-primary h2 mb-0"
/>
</b-col>
<b-col
cols="9"
>
<b-card-body
:title="type.title"
class="px-2"
>
<b-card-text>
{{ type.description }}
</b-card-text>
</b-card-body>
</b-col>
<b-col
cols="1"
align-self="center"
>
<font-awesome-icon
:icon="['fas', 'chevron-right']"
/>
</b-col>
</b-row>
<a
v-if="type.href"
:href="type.href"
target="_blank"
class="pointer stretched-link"
/>
<router-link
v-else-if="type.to"
:to="type.to"
class="pointer stretched-link"
/>
</b-card>
</b-col>
</b-row>
</b-col>
</b-row>
</b-container>
</template>
<script>
export default {
name: 'DataOverview',
i18nOptions: {
namespaces: 'data-overview',
},
data () {
return {
dataTypes: [
{
title: this.$t('data-types.profile-information.title'),
description: this.$t('data-types.profile-information.description'),
icon: ['far', 'user'],
href: this.$auth.cortezaAuthURL,
},
{
title: this.$t('data-types.application-data.title'),
description: this.$t('data-types.application-data.description'),
icon: ['fas', 'th-large'],
to: { name: 'data-overview.application' },
},
],
}
},
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<b-container
class="d-flex flex-column p-3"
>
<request-editor
v-if="kind"
:kind="kind"
@submit="onSubmit"
/>
</b-container>
</template>
<script>
import RequestEditor from 'corteza-webapp-privacy/src/components/Requests/Editor'
export default {
name: 'RequestView',
i18nOptions: {
namespaces: 'request',
keyPrefix: 'create',
},
components: {
RequestEditor,
},
props: {
kind: {
type: String,
required: true,
},
},
data () {
return {
processing: false,
}
},
methods: {
onSubmit ({ kind, payload }) {
this.processing = true
payload = [payload]
return this.$SystemAPI.dataPrivacyRequestCreate({ kind, payload })
.then(({ requestID, kind } = {}) => {
this.$router.push({ name: 'request.view', params: { requestID, kind } })
})
.catch(this.toastErrorHandler(this.$t('notification:list.load.error')))
.finally(() => {
this.processing = false
})
},
},
}
</script>

View File

@@ -0,0 +1,281 @@
<template>
<b-container
v-if="isDC !== null"
fluid
class="d-flex flex-column p-3"
>
<portal to="topbar-title">
{{ $t('title') }}
</portal>
<c-resource-list
primary-key="requestID"
:items="items"
:fields="fields"
:filter="filter"
:sorting="sorting"
:pagination="pagination"
:translations="{
notFound: $t('general:resourceList.notFound'),
noItems: $t('general:resourceList.noItems'),
loading: $t('general:resourceList.loading'),
searchPlaceholder: $t('general:resourceList.search.placeholder'),
showingPagination: 'general:resourceList.pagination.showing',
singlePluralPagination: 'general:resourceList.pagination.single',
prevPagination: $t('general:resourceList.pagination.prev'),
nextPagination: $t('general:resourceList.pagination.next'),
resourceSingle: $t('general:label.privacy_request.single'),
resourcePlural: $t('general:label.privacy_request.plural'),
}"
:is-item-selectable="isItemSelectable"
selectable
clickable
hide-total
class="flex-grow-1"
@search="filterList"
@row-clicked="rowClicked"
>
<template #header="{ selected = [] }">
<template v-if="isDC">
<c-input-confirm
v-if="isDC"
:disabled="processing || !selected.length"
:processing="processingApprove"
:text="$t('request.approve')"
variant="primary"
size="lg"
size-confirm="lg"
@confirmed="handleSelectedRequests(selected, 'approved')"
/>
<c-input-confirm
v-if="isDC"
:disabled="processing || !selected.length"
:processing="processingReject"
:text="$t('request.reject')"
variant="danger"
size="lg"
size-confirm="lg"
@confirmed="handleSelectedRequests(selected, 'rejected')"
/>
</template>
<template v-else>
<c-input-confirm
:disabled="processing || !selected.length"
:processing="processingCancel"
:text="$t('request.cancel')"
variant="light"
size="lg"
size-confirm="lg"
@confirmed="handleSelectedRequests(selected, 'canceled')"
/>
<!-- <b-button
:disabled="processing"
variant="light"
size="lg"
class="ml-1"
@click="exportRequests()"
>
{{ $t('export') }}
</b-button> -->
</template>
</template>
<template #status="{ item }">
<div
class="d-flex align-items-baseline"
>
<span
class="d-inline-block rounded-circle mr-1"
:class="`bg-${statusVariants[item.status]}`"
style="width: 0.6rem; height: 0.6rem;"
/>
{{ $t(`request:status.${item.status}`) }}
</div>
</template>
</c-resource-list>
</b-container>
</template>
<script>
import moment from 'moment'
import listHelpers from 'corteza-webapp-privacy/src/mixins/listHelpers'
import { components } from '@cortezaproject/corteza-vue'
const { CResourceList } = components
export default {
name: 'RequestList',
components: {
CResourceList,
},
mixins: [
listHelpers,
],
i18nOptions: {
namespaces: 'request',
keyPrefix: 'list',
},
data () {
return {
processing: false,
processingApprove: false,
processingReject: false,
processingCancel: false,
isDC: null,
users: {},
filter: {
requestedBy: [],
},
sorting: {
sortBy: 'requestedAt',
sortDesc: true,
},
statusVariants: {
canceled: 'secondary',
pending: 'warning',
rejected: 'danger',
approved: 'success',
},
}
},
computed: {
fields () {
return [
{
key: 'kind',
sortable: true,
formatter: kind => this.$t(`request:kind.${kind}`),
},
{
key: 'requestedAt',
sortable: true,
formatter: requestedAt => moment(requestedAt).fromNow(),
},
{
hide: !this.isDC,
key: 'requestedBy',
sortable: false,
formatter: requestedBy => this.formatUser(requestedBy),
},
{
key: 'status',
sortable: true,
},
].filter(({ hide }) => !hide)
.map(c => ({
...c,
// Generate column label translation key
label: c.label || this.$t(`columns.${c.key}`),
}))
},
},
created () {
this.checkIsDC()
},
methods: {
checkIsDC () {
this.$SystemAPI.roleList({ query: 'data-privacy-officer', memberID: this.$auth.user.userID })
.then(({ set = [] }) => {
if (!set.length) {
this.filter.requestedBy = [this.$auth.user.userID]
}
this.isDC = !!set.length
})
},
encodeRouteParams () {
const { query } = this.filter
const { limit, pageCursor, page } = this.pagination
return {
query: {
limit,
...this.sorting,
query,
page,
pageCursor,
},
}
},
items () {
return this.procListResults(this.$SystemAPI.dataPrivacyRequestListCancellable(this.encodeListParams()))
.then(async (set) => {
if (this.isDC && set && set.length) {
await this.fetchUsers(set.map(({ requestedBy }) => requestedBy))
}
return set
})
},
handleSelectedRequests (selected, status) {
this.processing = true
if (status === 'approved') {
this.processingApprove = true
} else if (status === 'rejected') {
this.processingReject = true
} else {
this.processingCancel = true
}
Promise.all(selected.map(requestID => {
return this.$SystemAPI.dataPrivacyRequestUpdateStatus({ requestID, status })
}))
.then(() => {
this.$root.$emit('bv::refresh::table', 'resource-list')
})
.finally(() => {
this.processing = false
if (status === 'approved') {
this.processingApprove = false
} else if (status === 'rejected') {
this.processingReject = false
} else {
this.processingCancel = false
}
})
},
fetchUsers (userID = []) {
userID = [...new Set(userID)]
return this.$SystemAPI.userList({ userID, suspended: 1, deleted: 1 })
.then(({ set }) => {
set.forEach(user => {
this.users[user.userID] = user
})
})
},
isItemSelectable (item) {
return item.status === 'pending'
},
formatUser (userID) {
const { name, username, email, handle } = this.users[userID]
return name || username || email || handle || userID || ''
},
rowClicked ({ requestID, kind }) {
this.$router.push({ name: 'request.view', params: { requestID, kind } })
},
},
}
</script>

View File

@@ -0,0 +1,218 @@
<template>
<b-container
class="d-flex flex-column p-3"
>
<div
v-if="processing.request"
class="d-flex align-items-center justify-content-center h-100"
>
<b-spinner />
</div>
<template v-else-if="request">
<request-viewer
:request="request"
class="mb-3"
/>
<request-comments
:comments="comments"
:processing="processing.comments"
:sort="sort"
@sort="sort = $event"
@submit="submitComment"
/>
</template>
<portal to="editor-toolbar">
<editor-toolbar
:processing="processing.toolbar"
:processing-confirm="processingReject"
:back-link="{ name: 'request.list' }"
:delete-show="isDC"
:delete-disabled="!request || !isPending"
:delete-label="$t('reject')"
@delete="handleRequest('rejected')"
>
<c-input-confirm
v-if="!isDC"
:disabled="!request || !isPending"
:processing="processingCancel"
:text="$t('cancel')"
variant="light"
size="lg"
size-confirm="lg"
@confirmed="handleRequest('canceled')"
/>
<c-input-confirm
v-else
:disabled="!request || !isPending"
:processing="processingApprove"
:text="$t('approve')"
variant="primary"
variant-ok="primary"
size="lg"
size-confirm="lg"
class="ml-2"
@confirmed="handleRequest('approved')"
/>
</editor-toolbar>
</portal>
</b-container>
</template>
<script>
import EditorToolbar from 'corteza-webapp-privacy/src/components/Common/EditorToolbar'
import RequestViewer from 'corteza-webapp-privacy/src/components/Requests/Viewer'
import RequestComments from 'corteza-webapp-privacy/src/components/Requests/Comments'
export default {
name: 'RequestView',
i18nOptions: {
namespaces: 'request',
keyPrefix: 'view',
},
components: {
EditorToolbar,
RequestViewer,
RequestComments,
},
props: {
requestID: {
type: String,
required: false,
default: '',
},
},
data () {
return {
processing: {
comments: false,
request: false,
toolbar: false,
},
processingApprove: false,
processingReject: false,
processingCancel: false,
isDC: null,
sort: 'createdAt DESC',
request: undefined,
comments: [],
}
},
computed: {
isPending () {
return this.request.status === 'pending'
},
},
watch: {
requestID: {
immediate: true,
handler () {
if (this.requestID) {
this.fetchRequest()
this.fetchComments()
}
},
},
sort: {
handler () {
if (this.requestID) {
this.fetchComments()
}
},
},
},
created () {
this.checkIsDC()
},
methods: {
checkIsDC () {
this.$SystemAPI.roleList({ query: 'data-privacy-officer', memberID: this.$auth.user.userID })
.then(({ set = [] }) => {
this.isDC = !!set.length
})
},
fetchRequest (requestID = this.requestID) {
this.processing.request = true
return this.$SystemAPI.dataPrivacyRequestRead({ requestID })
.then(request => {
this.request = request
})
.catch(this.toastErrorHandler(this.$t('notification:list.load.error')))
.finally(() => {
this.processing.request = false
})
},
fetchComments (requestID = this.requestID) {
this.processing.comments = true
return this.$SystemAPI.dataPrivacyRequestCommentList({ requestID, sort: this.sort })
.then(({ set }) => {
this.comments = set
})
.catch(this.toastErrorHandler(this.$t('notification:list.load.error')))
.finally(() => {
this.processing.comments = false
})
},
handleRequest (status) {
this.processing.toolbar = true
if (status === 'approved') {
this.processingApprove = true
} else if (status === 'rejected') {
this.processingReject = true
} else {
this.processingCancel = true
}
this.$SystemAPI.dataPrivacyRequestUpdateStatus({ requestID: this.requestID, status })
.then(() => {
this.$router.push({ name: 'request.list' })
})
.finally(() => {
this.processing.toolbar = false
if (status === 'approved') {
this.processingApprove = false
} else if (status === 'rejected') {
this.processingReject = false
} else {
this.processingCancel = false
}
})
},
submitComment (comment) {
this.processing.comments = true
this.$SystemAPI.dataPrivacyRequestCommentCreate({ requestID: this.requestID, comment })
.then(() => {
return this.fetchComments()
})
.finally(() => {
this.processing.comments = false
})
},
},
}
</script>

View File

@@ -0,0 +1,116 @@
<template>
<b-container
fluid
class="d-flex flex-column p-3"
>
<portal to="topbar-title">
{{ $t('title') }}
</portal>
<c-resource-list
primary-key="moduleID"
:items="items"
:fields="fields"
:filter="filter"
:sorting="sorting"
:pagination="pagination"
:selectable="false"
:translations="{
noItems: $t('general:resourceList.noItems'),
loading: $t('general:resourceList.loading'),
searchPlaceholder: $t('general:resourceList.search.placeholder'),
showingPagination: 'general:resourceList.pagination.showing',
singlePluralPagination: 'general:resourceList.pagination.single',
prevPagination: $t('general:resourceList.pagination.prev'),
nextPagination: $t('general:resourceList.pagination.next'),
resourceSingle: $t('general:label.sensitive_module.single'),
resourcePlural: $t('general:label.sensitive_module.plural'),
}"
clickable
hide-total
class="flex-grow-1"
@search="filterList"
@row-clicked="rowClicked"
/>
</b-container>
</template>
<script>
import listHelpers from 'corteza-webapp-privacy/src/mixins/listHelpers'
import { components } from '@cortezaproject/corteza-vue'
const { CResourceList } = components
export default {
name: 'SensitiveData',
components: {
CResourceList,
},
mixins: [
listHelpers,
],
i18nOptions: {
namespaces: 'sensitive-data',
keyPrefix: 'list',
},
data () {
return {
fields: [
{
key: 'module',
formatter: module => {
return module ? module.name : ''
},
},
{
key: 'namespace',
formatter: namespace => {
return namespace ? namespace.name : ''
},
},
{
key: 'connection',
formatter: connection => {
const { name } = connection.meta || {}
return name
},
},
{
key: 'location',
formatter: (value, key, item) => {
const { meta = {} } = item.connection || {}
const { properties = {} } = meta.location || {}
return properties.name
},
},
{
key: 'ownership',
formatter: (value, key, item) => {
const { meta = {} } = item.connection || {}
const { ownership } = meta || {}
return ownership
},
},
].map(c => ({
...c,
// Generate column label translation key
label: c.label || this.$t(`columns.${c.key}`),
})),
}
},
methods: {
items () {
return this.procListResults(this.$ComposeAPI.dataPrivacyModuleListCancellable(this.encodeListParams()))
},
rowClicked ({ namespace, module }) {
const { namespaceID, slug } = namespace
window.open(`/compose/ns/${slug || namespaceID}/admin/modules/${module.moduleID}/edit`, '_blank')
},
},
}
</script>

View File

@@ -0,0 +1,19 @@
export default [
{
path: '',
name: 'root',
redirect: 'dashboard',
component: () => import('./Layout'),
children: [
{ name: 'dashboard', path: '/dashboard', component: () => import('./Privacy/Dashboard') },
{ name: 'sensitive-data', path: '/sensitive-data', component: () => import('./Privacy/SensitiveData') },
{ name: 'data-overview', path: '/data-overview', component: () => import('./Privacy/DataOverview/') },
{ name: 'data-overview.application', path: '/data-overview/application', component: () => import('./Privacy/DataOverview/Application') },
{ name: 'request.list', path: '/request/list', component: () => import('./Privacy/Request/List') },
{ name: 'request.view', path: '/request/:requestID?', component: () => import('./Privacy/Request/View'), props: true },
{ name: 'request.create', path: '/request/:kind/new', component: () => import('./Privacy/Request/Create'), props: true },
],
},
{ path: '*', redirect: { name: 'root' } },
]