Merge pull request #352 from YunoHost/enh-permissions

Enh permissions (new selectize + confirmation for ssh/sftp)
This commit is contained in:
Alexandre Aubin 2021-04-17 02:00:39 +02:00 committed by GitHub
commit 9e70259899
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 321 additions and 417 deletions

View file

@ -14,7 +14,6 @@ import { openWebSocket, getResponseData, handleError } from './handlers'
* @property {Boolean} wait - If `true`, will display the waiting modal.
* @property {Boolean} websocket - if `true`, will open a websocket connection.
* @property {Boolean} initial - if `true` and an error occurs, the dismiss button will trigger a go back in history.
* @property {Boolean} noCache - if `true`, will disable the cache mecanism for this call.
* @property {Boolean} asFormData - if `true`, will send the data with a body encoded as `"multipart/form-data"` instead of `"x-www-form-urlencoded"`).
*/

View file

@ -1,151 +0,0 @@
<template>
<div class="selectize-base">
<b-input-group>
<b-input-group-prepend is-text>
<icon iname="search-plus" />
<span class="ml-1">{{ label }}</span>
</b-input-group-prepend>
<b-form-input
:class="visible ? null : 'collapsed'"
aria-controls="collapse" :aria-expanded="visible ? 'true' : 'false'"
@focus="onInputFocus" @blur="onInputBlur" @keydown="onInputKeydown"
v-model="search" ref="input"
/>
</b-input-group>
<b-collapse ref="collapse" v-model="visible">
<b-list-group tabindex="-1" @mouseover="onChoiceListOver" v-if="visible">
<b-list-group-item
v-for="(item, index) in filteredChoices" :key="item"
tabindex="-1" :active="index === focusedIndex" ref="choiceList"
@mousedown.prevent @mouseup.prevent="onSelect(item)"
>
{{ item | filter(format) }}
</b-list-group-item>
</b-list-group>
</b-collapse>
</div>
</template>
<script>
// FIXME add accessibility to ChoiceList
export default {
name: 'BaseSelectize',
props: {
choices: { type: Array, required: true },
label: { type: String, default: null },
// FIXME find a better way to pass filters
format: { type: Function, default: null }
},
data: () => ({
visible: false,
search: '',
focusedIndex: 0
}),
computed: {
filteredChoices () {
const search = this.search.toLowerCase()
return this.choices.filter(item => {
return item.toLowerCase().includes(search)
}).sort()
}
},
methods: {
onInputFocus ({ relatedTarget }) {
this.visible = true
this.focusedIndex = 0
// timeout needed else scrollIntoView won't work
if (!this.$refs.choiceList) return
setTimeout(() => {
this.$refs.choiceList[0].scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' })
}, 50)
},
onInputBlur ({ relatedTarget }) {
if (!this.$refs.collapse.$el.contains(relatedTarget)) {
this.visible = false
}
},
onInputKeydown (e) {
const { key } = e
const choicesLen = this.filteredChoices.length
if (choicesLen < 1) return
if (key === 'ArrowDown') {
e.preventDefault()
if (this.focusedIndex <= choicesLen) {
this.focusedIndex++
}
} else if (key === 'ArrowUp') {
e.preventDefault()
if (this.focusedIndex > 0) {
this.focusedIndex--
}
} else if (key === 'Enter') {
this.onSelect(this.filteredChoices[this.focusedIndex])
this.focusedIndex = 0
} else {
this.focusedIndex = 0
}
const elemToFocus = this.$refs.choiceList[this.focusedIndex]
if (elemToFocus) {
elemToFocus.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' })
}
},
onChoiceListOver ({ target }) {
const index = this.$refs.choiceList.indexOf(target)
if (index > -1) {
this.focusedIndex = index
}
},
onSelect (item) {
this.$emit('selected', { item, index: this.choices.indexOf(item) })
}
},
filters: {
filter: function (text, func) {
if (func) return func(text)
else return text
}
}
}
</script>
<style lang="scss" scoped>
.collapse {
position: relative;
width: 100%;
}
// disable collapse animation
.collapsing {
-webkit-transition: none;
transition: none;
display: none;
}
.list-group {
margin-top: .5rem;
max-height: 10rem;
overflow-y: auto;
position: absolute;
z-index: 10;
width: 100%;
}
.list-group-item {
padding-top: 0;
padding-bottom: 0;
min-height: 2rem;
line-height: 1.75rem;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,154 @@
<template>
<div class="tags-selectize">
<b-form-tags
v-bind="$attrs" v-on="$listeners"
:value="value" :id="id"
size="lg" class="p-0 border-0" no-outer-focus
>
<template v-slot="{ tags, disabled, addTag, removeTag }">
<ul v-if="!noTags && tags.length > 0" class="list-inline d-inline-block mb-2">
<li v-for="tag in tags" :key="id + '-' + tag" class="list-inline-item">
<b-form-tag
@remove="onRemoveTag({ option: tag, removeTag })"
:title="tag"
:disabled="disabled || disabledItems.includes(tag)"
variant="light"
class="border border-dark mb-2"
>
<icon v-if="tagIcon" :iname="tagIcon" /> {{ tag }}
</b-form-tag>
</li>
</ul>
<b-dropdown
ref="dropdown"
variant="outline-dark" block menu-class="w-100"
@keydown.native="onDropdownKeydown"
>
<template #button-content>
<icon iname="search-plus" /> {{ label }}
</template>
<b-dropdown-group class="search-group">
<b-dropdown-form @submit.stop.prevent="() => {}">
<b-form-group
:label="$t('search.for', { items: itemsName })"
label-cols-md="auto" label-size="sm" :label-for="id + '-search-input'"
:invalid-feedback="$t('search.not_found', { items: $tc('items.' + itemsName, 0) })"
:state="searchState" :disabled="disabled"
class="mb-0"
>
<b-form-input
ref="search-input" v-model="search"
:id="id + '-search-input'"
type="search" size="sm" autocomplete="off"
/>
</b-form-group>
</b-dropdown-form>
<b-dropdown-divider />
</b-dropdown-group>
<b-dropdown-item-button
v-for="option in availableOptions"
:key="option"
@click="onAddTag({ option, addTag })"
>
{{ option }}
</b-dropdown-item-button>
<b-dropdown-text v-if="!criteria && availableOptions.length === 0">
<icon iname="exclamation-triangle" />
{{ $t('items_verbose_items_left', { items: $tc('items.' + itemsName, 0) }) }}
</b-dropdown-text>
</b-dropdown>
</template>
</b-form-tags>
</div>
</template>
<script>
export default {
name: 'TagsSelectize',
props: {
value: { type: Array, required: true },
options: { type: Array, required: true },
id: { type: String, required: true },
itemsName: { type: String, required: true },
disabledItems: { type: Array, default: () => ([]) },
// By default `addTag` and `removeTag` have to be executed manually by listening to 'tag-update'.
auto: { type: Boolean, default: false },
noTags: { type: Boolean, default: false },
label: { type: String, default: null },
tagIcon: { type: String, default: null }
},
data () {
return {
search: ''
}
},
computed: {
criteria () {
return this.search.trim().toLowerCase()
},
availableOptions () {
const criteria = this.criteria
const options = this.options.filter(opt => {
return this.value.indexOf(opt) === -1 && !this.disabledItems.includes(opt)
})
if (criteria) {
return options.filter(opt => opt.toLowerCase().indexOf(criteria) > -1)
}
return options
},
searchState () {
return this.criteria && this.availableOptions.length === 0 ? false : null
}
},
methods: {
onAddTag ({ option, addTag }) {
this.$emit('tag-update', { action: 'add', option, applyMethod: addTag })
this.search = ''
if (this.auto) {
addTag(option)
}
},
onRemoveTag ({ option, removeTag }) {
this.$emit('tag-update', { action: 'remove', option, applyMethod: removeTag })
if (this.auto) {
removeTag(option)
}
},
onDropdownKeydown (e) {
// Allow to start searching after dropdown opening
if (
!['Tab', 'Space'].includes(e.code) &&
e.target === this.$refs.dropdown.$el.lastElementChild
) {
this.$refs['search-input'].focus()
}
}
}
}
</script>
<style lang="scss" scoped>
::v-deep .dropdown-menu {
max-height: 300px;
overflow-y: auto;
padding-top: 0;
.search-group {
padding-top: .5rem;
position: sticky;
top: 0;
background-color: white;
}
}
</style>

View file

@ -1,98 +0,0 @@
<template lang="html">
<div class="selectize-zone">
<div id="selected-items" v-if="selected.length > 0">
<b-button-group size="sm" v-for="item in filteredSelected" :key="item">
<b-button :to="itemRoute ? {name: itemRoute, params: {name: item}} : null" class="item-btn btn-light btn-outline-dark">
<icon :iname="itemIcon" /> {{ item | filter(format) }}
</b-button>
<b-button
v-if="!removable || removable(item)"
class="remove-btn btn-outline-dark" variant="warning"
@click="onRemove(item)"
>
<icon :title="$t('delete')" iname="minus" />
</b-button>
</b-button-group>
</div>
<base-selectize
v-if="choices.length"
:choices="choices"
:format="format"
:label="label"
@selected="$emit('change', { ...$event, action: 'add' })"
/>
</div>
</template>
<script>
import BaseSelectize from '@/components/BaseSelectize'
export default {
name: 'ZoneSelectize',
props: {
itemIcon: { type: String, default: null },
itemRoute: { type: String, default: null },
selected: { type: Array, required: true },
// needed by SelectizeBase
choices: { type: Array, required: true },
label: { type: String, default: null },
format: { type: Function, default: null },
removable: { type: Function, default: null }
},
data: () => ({
visible: false,
search: '',
focusedIndex: 0
}),
computed: {
filteredSelected () {
return [...this.selected].sort()
}
},
methods: {
onRemove (item) {
this.$emit('change', { item, index: this.selected.indexOf(item), action: 'remove' })
}
},
filters: {
filter: function (text, func) {
if (func) return func(text)
else return text
}
},
components: {
BaseSelectize
}
}
</script>
<style lang="scss" scoped>
#selected-items {
margin-bottom: .75rem;
display: flex;
flex-wrap: wrap;
.btn-group {
margin-right: .5rem;
margin-bottom: .5rem;
.item-btn {
.icon {
margin-right: .25rem;
}
}
}
}
.fa-minus {
position: relative;
top: 1px;
}
</style>

View file

@ -28,8 +28,8 @@
"confirm_app_default": "أمتأكد مِن أنك تود تعيين هذا التطبيق كبرنامج إفتراضي ؟",
"confirm_change_maindomain": "متأكد من أنك تريد تغيير النطاق الرئيسي ؟",
"confirm_delete": "هل تود حقًا حذف {name} ؟",
"confirm_firewall_open": "متأكد مِن أنك تود فتح منفذ {port} ؟ (بروتوكول : {protocol}، إتصال : {connection})",
"confirm_firewall_close": "متأكد مِن أنك تود إغلاق منفذ {port} ؟ (بروتوكول : {protocol}، إتصال : {connection})",
"confirm_firewall_allow": "متأكد مِن أنك تود فتح منفذ {port} ؟ (بروتوكول : {protocol}، إتصال : {connection})",
"confirm_firewall_disallow": "متأكد مِن أنك تود إغلاق منفذ {port} ؟ (بروتوكول : {protocol}، إتصال : {connection})",
"confirm_install_custom_app": "إنّ خيار تنصيب تطبيقات خارجية قد يؤثر على أمان نظامكم. ربما وجب عليكم ألا تقوموا بالتنصيب إلا إن كنتم حقا مدركون بما أنتم فاعلين. هل أنتم مستعدون للمخاطرة؟",
"confirm_install_domain_root": "لن يكون بإمكانك تنصيب أي برنامج آخر على {domain}. هل تريد المواصلة ؟",
"confirm_postinstall": "إنك بصدد إطلاق خطوة ما بعد التنصيب على النطاق {domain}. سوف تستغرق العملية بضع دقائق، لذلك *يُرجى عدم إيقاف العملية*.",

View file

@ -28,8 +28,8 @@
"confirm_app_default": "Està segur de voler fer aquesta aplicació predeterminada?",
"confirm_change_maindomain": "Està segur de voler canviar el domini principal?",
"confirm_delete": "Està segur de voler eliminar {name}?",
"confirm_firewall_open": "Està segur de voler obrir el port {port}? (protocol: {protocol}, connexió: {connection})",
"confirm_firewall_close": "Està segur de voler tancar el port {port}? (protocol: {protocol}, connexió: {connection})",
"confirm_firewall_allow": "Està segur de voler obrir el port {port}? (protocol: {protocol}, connexió: {connection})",
"confirm_firewall_disallow": "Està segur de voler tancar el port {port}? (protocol: {protocol}, connexió: {connection})",
"confirm_install_custom_app": "ATENCIÓ! La instal·lació d'aplicacions de terceres parts pot comprometre la integritat i seguretat del seu sistema. No hauríeu d'instal·lar-ne a no ser que sapigueu el que feu. Esteu segurs de voler córrer aquest risc?",
"confirm_install_domain_root": "No podrà instal·lar cap altra aplicació {domain}. Vol continuar?",
"confirm_migrations_skip": "Saltar-se les migracions no està recomanat. Està segur de voler continuar?",

View file

@ -185,8 +185,8 @@
"confirm_update_apps": "Möchtest du wirklich alle Anwendungen aktualisieren?",
"confirm_upnp_enable": "Möchtest du wirklich UPnP aktivieren?",
"confirm_upnp_disable": "Möchtest du wirklich UPnP deaktivieren?",
"confirm_firewall_open": "Möchtest du wirklich Port {port}1 öffnen? (Protokoll: {protocol}2, Verbindung: {connection}3)",
"confirm_firewall_close": "Möchtest du wirklich Port {port}1 schließen? (Protokoll: {protocol}2, Verbindung: {connection}3)",
"confirm_firewall_allow": "Möchtest du wirklich Port {port}1 öffnen? (Protokoll: {protocol}2, Verbindung: {connection}3)",
"confirm_firewall_disallow": "Möchtest du wirklich Port {port}1 schließen? (Protokoll: {protocol}2, Verbindung: {connection}3)",
"confirm_update_specific_app": "Möchtest du wirklich {app} aktualisieren?",
"confirm_reboot_action_reboot": "Möchtest du wirklich den Server neustarten?",
"confirm_reboot_action_shutdown": "Möchtest du wirklich den Server herunterfahren?",

View file

@ -94,6 +94,7 @@
"confirm_delete": "Are you sure you want to delete {name}?",
"confirm_firewall_allow": "Are you sure you want to open port {port} (protocol: {protocol}, connection: {connection})",
"confirm_firewall_disallow": "Are you sure you want to close port {port} (protocol: {protocol}, connection: {connection})",
"confirm_group_add_access_permission": "Are you sure you want to grant {perm} access to {name}? Such access significantly increases the attack surface if {name} happens to be a malicious person. You should only do so if you TRUST this person/group.",
"confirm_install_custom_app": "WARNING! Installing 3rd party applications may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
"confirm_install_domain_root": "Are you sure you want to install this application on '/'? You will not be able to install any other app on {domain}",
"confirm_app_install": "Are you sure you want to install this application?",
@ -242,10 +243,12 @@
"groups": "no groups | group | {c} groups",
"installed_apps": "no installed apps | installed app | {c} installed apps",
"logs": "no logs | log | {c} logs",
"permissions": "no permissions | permission | {c} permissions",
"services": "no services | service | {c} services",
"users": "no users | user | {c} users"
},
"items_verbose_count": "There is {items}.",
"items_verbose_count": "There are {items}.",
"items_verbose_items_left": "There are {items} left.",
"label": "Label",
"label_for_manifestname": "Label for {name}",
"last_ran": "Last time ran:",
@ -412,7 +415,7 @@
"save": "Save",
"search": {
"for": "Search for {items}...",
"not_found": "There is {items} matching your criteria."
"not_found": "There are {items} matching your criteria."
},
"select_all": "Select all",
"select_none": "Select none",

View file

@ -32,7 +32,7 @@
"hook_conf_ssh": "SSH",
"confirm_update_system": "Ĉu vi certas, ke vi volas ĝisdatigi ĉiujn sistemajn pakaĵojn ?",
"installation_complete": "Kompleta instalado",
"confirm_firewall_open": "Ĉu vi certas, ke vi volas malfermi havenojn {port} ? (Protokoloj {protocol}, konekto: {connection})",
"confirm_firewall_allow": "Ĉu vi certas, ke vi volas malfermi havenojn {port} ? (Protokoloj {protocol}, konekto: {connection})",
"confirm_postinstall": "Vi tuj lanĉos la postinstalaran procezon sur la domajno {domain}. Eble daŭras kelkajn minutojn, *ne interrompu la operacion*.",
"description": "priskribo",
"hook_conf_ynh_mysql": "MySQL pasvorto",
@ -84,7 +84,7 @@
"hook_data_mail": "Poŝto",
"backup_create": "Krei sekurkopion",
"confirm_uninstall": "Ĉu vi certas, ke vi volas malinstali {name} ?",
"confirm_firewall_close": "Ĉu vi certas, ke vi volas fermi havenon {port} ? (protokolo: {protocol}, rilato: {connection})",
"confirm_firewall_disallow": "Ĉu vi certas, ke vi volas fermi havenon {port} ? (protokolo: {protocol}, rilato: {connection})",
"created_at": "Kreita ĉe",
"confirm_app_change_url": "Ĉu vi certas, ke vi volas ŝanĝi la URL-aliron de la aplikaĵo?",
"ipv6": "IPv6",

View file

@ -180,8 +180,8 @@
"revert_to_selfsigned_cert_message": "Si realmente lo desea, puede reinstalar un certificado autofirmado. (No recomendado)",
"revert_to_selfsigned_cert": "Volver a un certificado autofirmado",
"user_mailbox_use": "Espacio utilizado",
"confirm_firewall_open": "¿Está seguro de que desea abrir el puerto {port}? (protocolo: {protocol}, conexión: {connection})",
"confirm_firewall_close": "¿Está seguro de que desea cerrar el puerto {port}? (protocolo: {protocol}, conexión: {connection})",
"confirm_firewall_allow": "¿Está seguro de que desea abrir el puerto {port}? (protocolo: {protocol}, conexión: {connection})",
"confirm_firewall_disallow": "¿Está seguro de que desea cerrar el puerto {port}? (protocolo: {protocol}, conexión: {connection})",
"confirm_service_start": "¿Está seguro de que desea iniciar {name}?",
"confirm_service_stop": "¿Está seguro de que desea parar {name}?",
"confirm_update_apps": "¿Está seguro de que desea actualizar todas las aplicaciones?",

View file

@ -180,8 +180,8 @@
"revert_to_selfsigned_cert_message": "Si vous le souhaitez vraiment, vous pouvez réinstaller un certificat auto-signé (non recommandé).",
"revert_to_selfsigned_cert": "Retourner à un certificat auto-signé",
"user_mailbox_use": "Espace utilisé de la boite aux lettres",
"confirm_firewall_open": "Voulez-vous vraiment ouvrir le port {port} ? (protocole : {protocol}, connexion : {connection})",
"confirm_firewall_close": "Voulez-vous vraiment fermer le port {port} ? (protocole : {protocol}, connexion : {connection})",
"confirm_firewall_allow": "Voulez-vous vraiment ouvrir le port {port} ? (protocole : {protocol}, connexion : {connection})",
"confirm_firewall_disallow": "Voulez-vous vraiment fermer le port {port} ? (protocole : {protocol}, connexion : {connection})",
"confirm_service_start": "Voulez-vous vraiment démarrer {name} ?",
"confirm_service_stop": "Voulez-vous vraiment arrêter {name} ?",
"confirm_update_apps": "Voulez-vous vraiment mettre à jour toutes les applications ?",

View file

@ -160,8 +160,8 @@
"app_info_changeurl_desc": "Cambia l'URL di accesso di questa applicazione (dominio e/o percorso).",
"app_info_change_url_disabled_tooltip": "Questa funzionalità non è ancora stata implementata in questa applicazione",
"confirm_app_change_url": "Sei sicuro di voler cambiare l'URL di accesso all'applicazione ?",
"confirm_firewall_open": "Sei sicuro di voler aprire la porta {port}? (protocollo: {protocol}, connessione: {connection})",
"confirm_firewall_close": "Sei sicuro di voler chiudere la porta {port}? (protocollo: {protocol}, connessione: {connection})",
"confirm_firewall_allow": "Sei sicuro di voler aprire la porta {port}? (protocollo: {protocol}, connessione: {connection})",
"confirm_firewall_disallow": "Sei sicuro di voler chiudere la porta {port}? (protocollo: {protocol}, connessione: {connection})",
"confirm_migrations_skip": "Saltare le migrazioni è sconsigliato. Sei sicuro di volerlo fare?",
"confirm_service_start": "Sei sicuro di voler eseguire {name}?",
"confirm_service_stop": "Sei sicuro di voler fermare {name}?",

View file

@ -28,8 +28,8 @@
"confirm_app_default": "Volètz vertadièrament definir aquesta aplicacion coma aplicacion per defaut?",
"confirm_change_maindomain": "Volètz vertadièrament cambiar lo domeni màger?",
"confirm_delete": "Volètz vertadièrament escafar {name}?",
"confirm_firewall_open": "Volètz vertadièrament dobrir lo pòrt {port}? (protocòl: {protocol}, connexion: {connection})",
"confirm_firewall_close": "Volètz vertadièrament tampar lo pòrt {port}? (protocòl: {protocol}, connexion: {connection})",
"confirm_firewall_allow": "Volètz vertadièrament dobrir lo pòrt {port}? (protocòl: {protocol}, connexion: {connection})",
"confirm_firewall_disallow": "Volètz vertadièrament tampar lo pòrt {port}? (protocòl: {protocol}, connexion: {connection})",
"confirm_install_custom_app": "Atencion! Linstallacion daplicacions tèrças pòt perilhar lintegritat e la seguretat del sistèma. Auriatz PAS de ninstallar levat que saupèssetz çò que fasètz. Volètz vertadièrament córrer aqueste risc?",
"confirm_install_domain_root": "Poiretz pas installar mai aplicacions sus {domain}. Contunhar?",
"confirm_migrations_skip": "Passar las migracions es pas recomandat. Volètz vertadièrament o far?",

View file

@ -30,8 +30,8 @@
"logout": "Wyloguj",
"ok": "OK",
"confirm_app_install": "Czy na pewno chcesz zainstalować tę aplikację?",
"confirm_firewall_close": "Czy na pewno chcesz zamknąć port {port} (protocol:{protocol}, connection: {connection})",
"confirm_firewall_open": "Czy na pewno chcesz otworzyć port {port} (protocol:{protocol}, connection: {connection})",
"confirm_firewall_disallow": "Czy na pewno chcesz zamknąć port {port} (protocol:{protocol}, connection: {connection})",
"confirm_firewall_allow": "Czy na pewno chcesz otworzyć port {port} (protocol:{protocol}, connection: {connection})",
"confirm_delete": "Czy na pewno chcesz usunąć {name}?",
"confirm_change_maindomain": "Czy na pewno chcesz zmienić domenę podstawową?",
"confirm_app_default": "Czy na pewno chcesz ustawić tę aplikację jako domyślną?",

View file

@ -137,8 +137,8 @@
"backup_new": "Nova cópia de segurança",
"check": "Verificação",
"confirm_app_change_url": "Tem certeza que quer mudar o endereço URL para acessar esta aplicação?",
"confirm_firewall_open": "Tem certeza que quer abrir a porta {port}? (protocolo: {protocol}, conexão: {connection})",
"confirm_firewall_close": "Tem certeza que quer fechar a porta {port}? (protocolo: {protocol}, conexão: {connection})",
"confirm_firewall_allow": "Tem certeza que quer abrir a porta {port}? (protocolo: {protocol}, conexão: {connection})",
"confirm_firewall_disallow": "Tem certeza que quer fechar a porta {port}? (protocolo: {protocol}, conexão: {connection})",
"confirm_install_custom_app": "CUIDADO! Instalar aplicações de terceiros pode comprometer a integridade e a segurança do seu sistema. Provavelmente NÃO deveria instalar esta aplicação se não tiver certeza do que está fazendo. Quer correr esses riscos?",
"confirm_install_domain_root": "Não será mas capaz de instalar outras aplicações em {domain}. Quer continuar?",
"confirm_install_app_lowquality": "Aviso: esta aplicação pode funcionar mais não está bem integrada em Yunohost. Algumas funcionalidades como logon único e/ou cópia de segurança/restauro pode não ser disponível.",

View file

@ -33,8 +33,8 @@
"confirm_app_default": "Вы хотите сделать это приложение приложением по умолчанию?",
"confirm_change_maindomain": "Вы хотите изменить главный домен?",
"confirm_delete": "Вы хотите удалить {name}1 ?",
"confirm_firewall_open": "Вы хотите открыть порт {port}1 ? (протокол {protocol}2, соединение {connection}3)",
"confirm_firewall_close": "Вы хотите закрыть порт {port}1 ? (протокол {protocol}2, соединение {connection}3)",
"confirm_firewall_allow": "Вы хотите открыть порт {port}1 ? (протокол {protocol}2, соединение {connection}3)",
"confirm_firewall_disallow": "Вы хотите закрыть порт {port}1 ? (протокол {protocol}2, соединение {connection}3)",
"confirm_install_custom_app": "Установка сторонних приложений может повредить безопасности вашей системы. Установка на вашу ответственность.",
"confirm_install_domain_root": "Вы больше не сможете устанавливать приложения на {domain}1. Продолжить?",
"confirm_restore": "Вы хотите восстановить {name}1 ?",

View file

@ -1,6 +1,7 @@
import Vue from 'vue'
import api from '@/api'
import { isEmptyValue } from '@/helpers/commons'
export default {
@ -14,31 +15,31 @@ export default {
}),
mutations: {
'SET_DOMAINS' (state, domains) {
'SET_DOMAINS' (state, [domains]) {
state.domains = domains
},
'ADD_DOMAINS' (state, { domain }) {
'ADD_DOMAINS' (state, [{ domain }]) {
state.domains.push(domain)
},
'DEL_DOMAINS' (state, domain) {
'DEL_DOMAINS' (state, [domain]) {
state.domains.splice(state.domains.indexOf(domain), 1)
},
'SET_MAIN_DOMAIN' (state, response) {
'SET_MAIN_DOMAIN' (state, [response]) {
state.main_domain = response.current_main_domain
},
'UPDATE_MAIN_DOMAIN' (state, domain) {
'UPDATE_MAIN_DOMAIN' (state, [domain]) {
state.main_domain = domain
},
'SET_USERS' (state, users) {
'SET_USERS' (state, [users]) {
state.users = Object.keys(users).length === 0 ? null : users
},
'ADD_USERS' (state, user) {
'ADD_USERS' (state, [user]) {
if (!state.users) state.users = {}
Vue.set(state.users, user.username, user)
},
@ -60,7 +61,7 @@ export default {
this.commit('SET_USERS_DETAILS', payload)
},
'DEL_USERS_DETAILS' (state, username) {
'DEL_USERS_DETAILS' (state, [username]) {
Vue.delete(state.users_details, username)
if (state.users) {
Vue.delete(state.users, username)
@ -70,60 +71,80 @@ export default {
}
},
'SET_GROUPS' (state, groups) {
'SET_GROUPS' (state, [groups]) {
state.groups = groups
},
'ADD_GROUPS' (state, { name }) {
'ADD_GROUPS' (state, [{ name }]) {
if (state.groups !== undefined) {
Vue.set(state.groups, name, { members: [], permissions: [] })
}
},
'DEL_GROUPS' (state, groupname) {
'UPDATE_GROUPS' (state, [data, { groupName }]) {
Vue.set(state.groups, groupName, data)
},
'DEL_GROUPS' (state, [groupname]) {
Vue.delete(state.groups, groupname)
},
'SET_PERMISSIONS' (state, permissions) {
'SET_PERMISSIONS' (state, [permissions]) {
state.permissions = permissions
},
'UPDATE_PERMISSIONS' (state, [_, { groupName, action, permId }]) {
// FIXME hacky way to update the store
const permissions = state.groups[groupName].permissions
if (action === 'add') {
permissions.push(permId)
} else if (action === 'remove') {
const index = permissions.indexOf(permId)
if (index > -1) permissions.splice(index, 1)
}
}
},
actions: {
'GET' ({ state, commit, rootState }, { uri, param, humanKey, storeKey = uri, options = {} }) {
const noCache = !rootState.cache || options.noCache || false
'GET' (
{ state, commit, rootState },
{ uri, param, storeKey = uri, humanKey, noCache, options, ...extraParams }
) {
const currentState = param ? state[storeKey][param] : state[storeKey]
// if data has already been queried, simply return
if (currentState !== undefined && !noCache) return currentState
const ignoreCache = !rootState.cache || noCache || false
if (currentState !== undefined && !ignoreCache) return currentState
return api.fetch('GET', param ? `${uri}/${param}` : uri, null, humanKey, options).then(responseData => {
const data = responseData[storeKey] ? responseData[storeKey] : responseData
commit('SET_' + storeKey.toUpperCase(), param ? [param, data] : data)
commit(
'SET_' + storeKey.toUpperCase(),
[param, data, extraParams].filter(item => !isEmptyValue(item))
)
return param ? state[storeKey][param] : state[storeKey]
})
},
'POST' ({ state, commit }, { uri, storeKey = uri, data, humanKey, options }) {
'POST' ({ state, commit }, { uri, storeKey = uri, data, humanKey, options, ...extraParams }) {
return api.fetch('POST', uri, data, humanKey, options).then(responseData => {
// FIXME api/domains returns null
if (responseData === null) responseData = data
responseData = responseData[storeKey] ? responseData[storeKey] : responseData
commit('ADD_' + storeKey.toUpperCase(), responseData)
commit('ADD_' + storeKey.toUpperCase(), [responseData, extraParams].filter(item => !isEmptyValue(item)))
return state[storeKey]
})
},
'PUT' ({ state, commit }, { uri, param, storeKey = uri, data, humanKey, options }) {
'PUT' ({ state, commit }, { uri, param, storeKey = uri, data, humanKey, options, ...extraParams }) {
return api.fetch('PUT', param ? `${uri}/${param}` : uri, data, humanKey, options).then(responseData => {
const data = responseData[storeKey] ? responseData[storeKey] : responseData
commit('UPDATE_' + storeKey.toUpperCase(), param ? [param, data] : data)
commit('UPDATE_' + storeKey.toUpperCase(), [param, data, extraParams].filter(item => !isEmptyValue(item)))
return param ? state[storeKey][param] : state[storeKey]
})
},
'DELETE' ({ commit }, { uri, param, storeKey = uri, data, humanKey, options }) {
'DELETE' ({ commit }, { uri, param, storeKey = uri, data, humanKey, options, ...extraParams }) {
return api.fetch('DELETE', param ? `${uri}/${param}` : uri, data, humanKey, options).then(() => {
commit('DEL_' + storeKey.toUpperCase(), param)
commit('DEL_' + storeKey.toUpperCase(), [param, extraParams].filter(item => !isEmptyValue(item)))
})
}
},

View file

@ -2,7 +2,7 @@
<view-search
items-name="groups"
:search.sync="search"
:items="normalGroups"
:items="primaryGroups"
:filtered-items="filteredGroups"
:queries="queries"
@queries-response="onQueriesResponse"
@ -16,13 +16,13 @@
<!-- PRIMARY GROUPS CARDS -->
<card
v-for="(group, name) in filteredGroups" :key="name" collapsable
:title="group.isSpecial ? $t('group_' + name) : `${$t('group')} '${name}'`" icon="group"
v-for="(group, groupName) in filteredGroups" :key="groupName" collapsable
:title="group.isSpecial ? $t('group_' + groupName) : `${$t('group')} '${groupName}'`" icon="group"
>
<template #header-buttons>
<!-- DELETE GROUP -->
<b-button
v-if="!group.isSpecial" @click="deleteGroup(name)"
v-if="!group.isSpecial" @click="deleteGroup(groupName)"
size="sm" variant="danger"
>
<icon iname="trash-o" /> {{ $t('delete') }}
@ -37,18 +37,18 @@
<b-col>
<template v-if="group.isSpecial">
<p class="text-primary">
<icon iname="info-circle" /> {{ $t('group_explain_' + name) }}
<icon iname="info-circle" /> {{ $t('group_explain_' + groupName) }}
</p>
<p class="text-primary" v-if="name === 'visitors'">
<p class="text-primary" v-if="groupName === 'visitors'">
<em>{{ $t('group_explain_visitors_needed_for_external_client') }}</em>
</p>
</template>
<template v-else>
<zone-selectize
:choices="group.availableMembers" :selected="group.members"
item-icon="user"
:label="$t('group_add_member')"
@change="onUserChanged({ ...$event, name })"
<tags-selectize
v-model="group.members" :options="usersOptions"
:id="groupName + '-users'" :label="$t('group_add_member')"
tag-icon="user" items-name="users"
@tag-update="onUserChanged({ ...$event, groupName })"
/>
</template>
</b-col>
@ -60,50 +60,45 @@
<strong>{{ $t('permissions') }}</strong>
</b-col>
<b-col>
<zone-selectize
item-icon="key-modern"
:choices="group.availablePermissions"
:selected="group.permissions"
:label="$t('group_add_permission')"
:format="formatPermission"
:removable="name === 'visitors' ? removable : null"
@change="onPermissionChanged({ ...$event, name, groupType: 'normal' })"
<tags-selectize
v-model="group.permissions" :options="permissionsOptions"
:id="groupName + '-perms'" :label="$t('group_add_permission')"
tag-icon="key-modern" items-name="permissions"
@tag-update="onPermissionChanged({ ...$event, groupName })"
:disabled-items="group.disabledItems"
/>
</b-col>
</b-row>
</card>
<!-- GROUP SPECIFIC CARD -->
<!-- USER GROUPS CARD -->
<card
v-if="userGroups" collapsable
:title="$t('group_specific_permissions')" icon="group"
>
<template v-for="(name, index) in userGroupsNames">
<b-row :key="name">
<template v-for="(userName, index) in activeUserGroups">
<b-row :key="userName">
<b-col md="3" lg="2">
<icon iname="user" /> <strong>{{ name }}</strong>
<icon iname="user" /> <strong>{{ userName }}</strong>
</b-col>
<b-col>
<zone-selectize
item-icon="key-modern" item-variant="dark"
:choices="userGroups[name].availablePermissions"
:selected="userGroups[name].permissions"
:label="$t('group_add_permission')"
:format="formatPermission"
@change="onPermissionChanged({ ...$event, name, groupType: 'user' })"
<tags-selectize
v-model="userGroups[userName].permissions" :options="permissionsOptions"
:id="userName + '-perms'" :label="$t('group_add_permission')"
tag-icon="key-modern" items-name="permissions"
@tag-update="onPermissionChanged({ ...$event, groupName: userName })"
/>
</b-col>
</b-row>
<hr :key="index">
</template>
<base-selectize
v-if="availableMembers.length"
:label="$t('group_add_member')"
:choices="availableMembers"
:selected="userGroupsNames"
@selected="onSpecificUserAdded"
<tags-selectize
v-model="activeUserGroups" :options="usersOptions"
id="user-groups" :label="$t('group_add_member')"
no-tags items-name="users"
@tag-update="onSpecificUserAdded"
/>
</card>
</view-search>
@ -114,8 +109,7 @@ import Vue from 'vue'
import api from '@/api'
import { isEmptyValue } from '@/helpers/commons'
import ZoneSelectize from '@/components/ZoneSelectize'
import BaseSelectize from '@/components/BaseSelectize'
import TagsSelectize from '@/components/TagsSelectize'
// TODO add global search with type (search by: group, user, permission)
// TODO add vuex store update on inputs ?
@ -123,8 +117,7 @@ export default {
name: 'GroupList',
components: {
ZoneSelectize,
BaseSelectize
TagsSelectize
},
data () {
@ -136,134 +129,117 @@ export default {
],
search: '',
permissions: undefined,
normalGroups: undefined,
userGroups: undefined
permissionsOptions: undefined,
primaryGroups: undefined,
userGroups: undefined,
usersOptions: undefined,
activeUserGroups: undefined
}
},
computed: {
filteredGroups () {
const groups = this.normalGroups
const groups = this.primaryGroups
if (!groups) return
const search = this.search.toLowerCase()
const filtered = {}
for (const name in groups) {
if (name.toLowerCase().includes(search)) {
filtered[name] = groups[name]
for (const groupName in groups) {
if (groupName.toLowerCase().includes(search)) {
filtered[groupName] = groups[groupName]
}
}
return isEmptyValue(filtered) ? null : filtered
},
userGroupsNames () {
const groups = this.userGroups
if (!groups) return
return Object.keys(groups).filter(name => {
return groups[name].permissions !== null
})
},
availableMembers () {
const groups = this.userGroups
if (!groups) return
return Object.keys(groups).filter(name => {
return groups[name].permissions === null
})
}
},
methods: {
onQueriesResponse (users, allGroups, permissions) {
onQueriesResponse (users, allGroups, permsDict) {
// Do not use computed properties to get values from the store here to avoid auto
// updates while modifying values.
const normalGroups = {}
const userGroups = {}
const permissions = Object.entries(permsDict).map(([id, value]) => ({ id, ...value }))
const userNames = users ? Object.keys(users) : []
const primaryGroups = {}
const userGroups = {}
for (const groupName in allGroups) {
// copy the group to unlink it from the store
const group = { ...allGroups[groupName] }
group.availablePermissions = Object.keys(permissions).filter(perm => {
// Remove 'email', 'xmpp' and protected permissions in visitors's permission choice list
if (groupName === 'visitors' && (['mail.main', 'xmpp.main'].includes(perm) || permissions[perm].protected)) {
return false
}
return !group.permissions.includes(perm)
group.permissions = group.permissions.map((perm) => {
return permsDict[perm].label
})
if (userNames.includes(groupName)) {
if (group.permissions.length === 0) {
// This forbid the user to appear in the displayed user list
group.permissions = null
}
userGroups[groupName] = group
continue
}
if (['visitors', 'all_users'].includes(groupName)) {
group.isSpecial = true
} else {
group.availableMembers = userNames.filter(name => {
return !group.members.includes(name)
})
group.isSpecial = ['visitors', 'all_users'].includes(groupName)
if (groupName === 'visitors') {
// Forbid to add or remove a protected permission on group `visitors`
group.disabledItems = permissions.filter(({ id }) => {
return ['mail.main', 'xmpp.main'].includes(id) || permsDict[id].protected
}).map(({ id }) => permsDict[id].label)
}
normalGroups[groupName] = group
primaryGroups[groupName] = group
}
this.permissions = permissions
this.normalGroups = normalGroups
this.userGroups = isEmptyValue(userGroups) ? null : userGroups
},
const activeUserGroups = Object.entries(userGroups).filter(([_, group]) => {
return group.permissions.length > 0
}).map(([name]) => name)
onPermissionChanged ({ item, index, name, groupType, action }) {
// const uri = 'users/permissions/' + item
// const data = { [action]: name }
const from = action === 'add' ? 'availablePermissions' : 'permissions'
const to = action === 'add' ? 'permissions' : 'availablePermissions'
api.put(
`users/permissions/${item}/${action}/${name}`,
{},
{ key: 'permissions.' + action, perm: item.replace('.main', ''), name }
).then(() => {
this[groupType + 'Groups'][name][from].splice(index, 1)
this[groupType + 'Groups'][name][to].push(item)
Object.assign(this, {
permissions,
permissionsOptions: permissions.map(perm => perm.label),
primaryGroups,
userGroups: isEmptyValue(userGroups) ? null : userGroups,
usersOptions: userNames,
activeUserGroups
})
},
onUserChanged ({ item, index, name, action }) {
const from = action === 'add' ? 'availableMembers' : 'members'
const to = action === 'add' ? 'members' : 'availableMembers'
async onPermissionChanged ({ option, groupName, action, applyMethod }) {
const permId = this.permissions.find(perm => perm.label === option).id
if (action === 'add' && ['sftp.main', 'ssh.main'].includes(permId)) {
const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_group_add_access_permission', { name: groupName, perm: option })
)
if (!confirmed) return
}
api.put(
`users/groups/${name}/${action}/${item}`,
// FIXME hacky way to update the store
{ uri: `users/permissions/${permId}/${action}/${groupName}`, storeKey: 'permissions', groupName, action, permId },
{},
{ key: 'groups.' + action, user: item, name }
).then(() => {
this.normalGroups[name][from].splice(index, 1)
this.normalGroups[name][to].push(item)
})
{ key: 'permissions.' + action, perm: option, name: groupName }
).then(() => applyMethod(option))
},
onSpecificUserAdded ({ item }) {
this.userGroups[item].permissions = []
onUserChanged ({ option, groupName, action, applyMethod }) {
api.put(
{ uri: `users/groups/${groupName}/${action}/${option}`, storeKey: 'groups', groupName },
{},
{ key: 'groups.' + action, user: option, name: groupName }
).then(() => applyMethod(option))
},
// FIXME Find a way to pass a filter to a component
formatPermission (name) {
return this.permissions[name].label
onSpecificUserAdded ({ option: userName, action, applyMethod }) {
if (action === 'add') {
this.userGroups[userName].permissions = []
applyMethod(userName)
}
},
removable (name) {
return this.permissions[name].protected === false
},
async deleteGroup (name) {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name }))
async deleteGroup (groupName) {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: groupName }))
if (!confirmed) return
api.delete(
{ uri: 'users/groups', param: name, storeKey: 'groups' }, {}, { key: 'groups.delete', name }
{ uri: 'users/groups', param: groupName, storeKey: 'groups' },
{},
{ key: 'groups.delete', name: groupName }
).then(() => {
Vue.delete(this.normalGroups, name)
Vue.delete(this.primaryGroups, groupName)
})
}
}