Switch to composition API
This commit is contained in:
parent
f70c73e28a
commit
5652e62c68
3
Makefile
3
Makefile
@ -39,9 +39,10 @@ image: clean
|
||||
push: image
|
||||
docker push dnknth/ldap-ui:$(TAG)
|
||||
|
||||
manifest:
|
||||
manifest: push
|
||||
docker manifest create \
|
||||
dnknth/ldap-ui \
|
||||
--amend dnknth/ldap-ui:latest-x86_64 \
|
||||
--amend dnknth/ldap-ui:latest-aarch64
|
||||
docker manifest push --purge dnknth/ldap-ui
|
||||
docker compose pull
|
||||
|
BIN
package-lock.json
generated
BIN
package-lock.json
generated
Binary file not shown.
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ldap-ui",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 72 KiB |
205
src/App.vue
205
src/App.vue
@ -1,42 +1,43 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<nav-bar v-model:treeOpen="treeOpen" :dn="activeDn"
|
||||
@show-modal="modal = $event;" @select-dn="activeDn = $event;" />
|
||||
<div id="app" v-if="user">
|
||||
<nav-bar v-model:treeOpen="treeOpen" :dn="activeDn" :base-dn="baseDn" :user="user"
|
||||
@show-modal="modal = $event;" @select-dn="activeDn = $event;" @show-oc="oc = $event;" />
|
||||
|
||||
<ldif-import-dialog v-model:modal="modal" @ok="activeDn = '-';" />
|
||||
|
||||
<div class="flex container">
|
||||
<div class="space-y-4"><!-- left column -->
|
||||
<tree-view v-model:activeDn="activeDn" v-show="treeOpen" @base-dn="baseDn = $event;" />
|
||||
<object-class-card v-model="oc" />
|
||||
<attribute-card v-model="attr" />
|
||||
<object-class-card v-model="oc" @show-attr="attr = $event;" @show-oc="oc = $event;" />
|
||||
<attribute-card v-model="attr" @show-attr="attr = $event;" />
|
||||
</div>
|
||||
|
||||
<div class="flex-auto mt-4"><!-- main editing area -->
|
||||
<transition name="fade"><!-- Notifications -->
|
||||
<div v-if="error"
|
||||
class="rounded mx-4 mb-4 p-3 border border-front/70 text-back/70" :class="error.type">
|
||||
<div v-if="error" :class="error.cssClass"
|
||||
class="rounded mx-4 mb-4 p-3 border border-front/70 text-front/70 dark:text-back/70">
|
||||
{{ error.msg }}
|
||||
<span class="float-right control" @click="error = undefined">✖</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<entry-editor v-model:activeDn="activeDn" />
|
||||
<entry-editor v-model:activeDn="activeDn" :user="user"
|
||||
@show-attr="attr = $event;" @show-oc="oc = $event;"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="false"><!-- Not rendered, prevents color pruning -->
|
||||
<span class="text-accent bg-accent"></span>
|
||||
<span class="text-primary bg-primary"></span>
|
||||
<span class="text-back bg-back"></span>
|
||||
<span class="text-danger bg-danger"></span>
|
||||
<span class="text-front bg-front"></span>
|
||||
<span class="text-primary bg-primary"></span>
|
||||
<span class="text-secondary bg-secondary"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { onMounted, provide, readonly, ref, watch } from 'vue';
|
||||
import AttributeCard from './components/schema/AttributeCard.vue';
|
||||
import EntryEditor from './components/editor/EntryEditor.vue';
|
||||
import { LdapSchema } from './components/schema/schema.js';
|
||||
@ -46,102 +47,89 @@
|
||||
import { request } from './request.js';
|
||||
import TreeView from './components/TreeView.vue';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
const
|
||||
// Authentication
|
||||
user = ref(null), // logged in user
|
||||
baseDn = ref(null),
|
||||
|
||||
// Components
|
||||
treeOpen = ref(true), // Is the tree visible?
|
||||
activeDn = ref(null), // currently active DN in the editor
|
||||
modal = ref(null), // modal popup
|
||||
|
||||
components: {
|
||||
AttributeCard,
|
||||
EntryEditor,
|
||||
LdifImportDialog,
|
||||
NavBar,
|
||||
ObjectClassCard,
|
||||
TreeView,
|
||||
},
|
||||
// Alerts
|
||||
error = ref(null), // status alert
|
||||
|
||||
// LDAP schema
|
||||
schema = ref(null),
|
||||
oc = ref(null), // objectClass info in side panel
|
||||
attr = ref(null), // attribute info in side panel
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
// Helpers for components
|
||||
provided = {
|
||||
get schema() { return readonly(schema.value); },
|
||||
showInfo: showInfo,
|
||||
showWarning: showWarning,
|
||||
xhr: xhr,
|
||||
};
|
||||
|
||||
// authentication
|
||||
user: undefined, // logged in user
|
||||
baseDn: undefined,
|
||||
|
||||
// components
|
||||
treeOpen: true, // Is the tree visible?
|
||||
activeDn: undefined, // currently active DN in the editor
|
||||
modal: null, // modal popup
|
||||
provide('app', provided);
|
||||
|
||||
// alerts
|
||||
error: undefined, // status alert
|
||||
|
||||
// schema
|
||||
schema: new LdapSchema({}),
|
||||
onMounted(async () => { // Runs on page load
|
||||
// Get the DN of the current user
|
||||
user.value = await xhr({ url: 'api/whoami'});
|
||||
|
||||
// Flash cards
|
||||
oc: undefined, // objectClass info in side panel
|
||||
attr: undefined, // attribute info in side panel
|
||||
};
|
||||
},
|
||||
// Load the schema
|
||||
schema.value = new LdapSchema(await xhr({ url: 'api/schema' }));
|
||||
});
|
||||
|
||||
provide: function () {
|
||||
return {
|
||||
app: this,
|
||||
};
|
||||
},
|
||||
watch(attr, (a) => { if (a) oc.value = undefined; });
|
||||
watch(oc, (o) => { if (o) attr.value = undefined; });
|
||||
|
||||
mounted: async function() { // Runs on page load
|
||||
// Get the DN of the current user
|
||||
this.user = await this.xhr({ url: 'api/whoami'});
|
||||
function xhr(options) {
|
||||
if (options.data && !options.binary) {
|
||||
if (!options.headers) options.headers = {}
|
||||
if (!options.headers['Content-Type']) {
|
||||
options.headers['Content-Type'] = 'application/json; charset=utf-8';
|
||||
}
|
||||
}
|
||||
return request(options)
|
||||
.then(xhr => JSON.parse(xhr.response))
|
||||
.catch(xhr => showException(xhr.response || "Unknown error"));
|
||||
}
|
||||
|
||||
// Display an info popup
|
||||
function showInfo(msg) {
|
||||
error.value = { counter: 5, cssClass: 'bg-emerald-300', msg: '' + msg };
|
||||
setTimeout(() => { error.value = null; }, 5000);
|
||||
}
|
||||
|
||||
// Flash a warning popup
|
||||
function showWarning(msg) {
|
||||
error.value = { counter: 10, cssClass: 'bg-amber-200', msg: '⚠️ ' + msg };
|
||||
setTimeout(() => { error.value = null; }, 10000);
|
||||
}
|
||||
|
||||
// Report an error
|
||||
function showError(msg) {
|
||||
error.value = { counter: 60, cssClass: 'bg-red-300', msg: '⛔ ' + msg };
|
||||
setTimeout(() => { error.value = null; }, 60000);
|
||||
}
|
||||
|
||||
// Load the schema
|
||||
this.schema = new LdapSchema(await this.xhr({ url: 'api/schema' }));
|
||||
},
|
||||
|
||||
watch: {
|
||||
attr: function(a) { if (a) this.oc = undefined; },
|
||||
oc: function(o) { if (o) this.attr = undefined; },
|
||||
},
|
||||
|
||||
methods: {
|
||||
xhr: function(options) {
|
||||
return request(options)
|
||||
.then(xhr => JSON.parse(xhr.response))
|
||||
.catch(xhr => this.showException(xhr.response || "Unknown error"));
|
||||
},
|
||||
|
||||
// Display an info popup
|
||||
showInfo: function(msg) {
|
||||
this.error = { counter: 5, type: 'success', msg: '' + msg }
|
||||
setTimeout(() => { this.error = undefined; }, 5000);
|
||||
},
|
||||
|
||||
// Flash a warning popup
|
||||
showWarning: function(msg) {
|
||||
this.error = { counter: 10, type: 'warning', msg: '⚠️ ' + msg }
|
||||
setTimeout(() => { this.error = undefined; }, 10000);
|
||||
},
|
||||
|
||||
// Report an error
|
||||
showError: function(msg) {
|
||||
this.error = { counter: 60, type: 'danger', msg: '⛔ ' + msg }
|
||||
setTimeout(() => { this.error = undefined; }, 60000);
|
||||
},
|
||||
|
||||
showException: function(msg) {
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = msg.replace("\n", " ");
|
||||
const titles = span.getElementsByTagName('title');
|
||||
for (let i = 0; i < titles.length; ++i) {
|
||||
span.removeChild(titles[i]);
|
||||
}
|
||||
let text = '';
|
||||
const headlines = span.getElementsByTagName('h1');
|
||||
for (let i = 0; i < headlines.length; ++i) {
|
||||
text = text + headlines[i].textContent + ': ';
|
||||
span.removeChild(headlines[i]);
|
||||
}
|
||||
this.showError(text + ' ' + span.textContent);
|
||||
},
|
||||
},
|
||||
function showException(msg) {
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = msg.replace("\n", " ");
|
||||
const titles = span.getElementsByTagName('title');
|
||||
for (let i = 0; i < titles.length; ++i) {
|
||||
span.removeChild(titles[i]);
|
||||
}
|
||||
let text = '';
|
||||
const headlines = span.getElementsByTagName('h1');
|
||||
for (let i = 0; i < headlines.length; ++i) {
|
||||
text = text + headlines[i].textContent + ': ';
|
||||
span.removeChild(headlines[i]);
|
||||
}
|
||||
showError(text + ' ' + span.textContent);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -151,7 +139,12 @@
|
||||
}
|
||||
|
||||
button, .btn, [type="button"] {
|
||||
@apply border-none px-3 py-2 rounded text-back dark:text-front;
|
||||
@apply border-none px-3 py-2 rounded text-back dark:text-front font-medium;
|
||||
}
|
||||
|
||||
select {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2IDEwIj4KICA8cG9seWdvbiBmaWxsPSJncmF5IiBwb2ludHM9IjEuNDEgNC42NyAyLjQ4IDMuMTggMy41NCA0LjY3IDEuNDEgNC42NyIgLz4KICA8cG9seWdvbiBmaWxsPSJncmF5IiBwb2ludHM9IjMuNTQgNS4zMyAyLjQ4IDYuODIgMS40MSA1LjMzIDMuNTQgNS4zMyIgLz4KPC9zdmc+) no-repeat right;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
@ -159,18 +152,6 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.success {
|
||||
@apply bg-emerald-300;
|
||||
}
|
||||
|
||||
.danger {
|
||||
@apply bg-red-300;
|
||||
}
|
||||
|
||||
.warning {
|
||||
@apply bg-amber-200;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
@ -1,68 +1,51 @@
|
||||
<template>
|
||||
<modal title="Import" :open="modal == 'ldif-import'" ok-title="Import"
|
||||
@show="init" @ok="onOk" @cancel="$emit('update:modal')">
|
||||
@show="init" @ok="onOk" @cancel="emit('update:modal')">
|
||||
<textarea v-model="ldifData" id="ldif-data" placeholder="Paste or upload LDIF"></textarea>
|
||||
<input type="file" @change="upload" accept=".ldif" />
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
import Modal from './ui/Modal.vue';
|
||||
|
||||
export default {
|
||||
name: 'LdifImportDialog',
|
||||
const
|
||||
app = inject('app'),
|
||||
ldifData = ref(''),
|
||||
ldifFile = ref(null),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
defineProps({ modal: String });
|
||||
|
||||
function init() {
|
||||
ldifData.value = '';
|
||||
ldifFile.value = null;
|
||||
}
|
||||
|
||||
// Load LDIF from file
|
||||
function upload(evt) {
|
||||
const file = evt.target.files[0],
|
||||
reader = new FileReader();
|
||||
|
||||
inject: [ 'app' ],
|
||||
reader.onload = function() {
|
||||
ldifData.value = reader.result;
|
||||
evt.target.value = null;
|
||||
}
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
// Import LDIF
|
||||
async function onOk() {
|
||||
if (!ldifData.value) return;
|
||||
|
||||
props: {
|
||||
modal: String,
|
||||
},
|
||||
emit('update:modal');
|
||||
const data = await app.xhr({
|
||||
url: 'api/ldif',
|
||||
method: 'POST',
|
||||
data: ldifData.value,
|
||||
});
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
ldifData: '',
|
||||
ldifFile: null,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
init: function() {
|
||||
this.ldifData = '';
|
||||
this.ldifFile = null;
|
||||
},
|
||||
|
||||
// Load LDIF from file
|
||||
upload: function(evt) {
|
||||
const file = evt.target.files[0],
|
||||
reader = new FileReader(),
|
||||
vm = this;
|
||||
reader.onload = function() {
|
||||
vm.ldifData = reader.result;
|
||||
evt.target.value = null;
|
||||
}
|
||||
reader.readAsText(file);
|
||||
},
|
||||
|
||||
// Import LDIF
|
||||
onOk: async function() {
|
||||
if (!this.ldifData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('update:modal');
|
||||
const data = await this.app.xhr({
|
||||
url: 'api/ldif',
|
||||
method: 'POST',
|
||||
data: this.ldifData,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||
});
|
||||
|
||||
if (data) this.$emit('ok');
|
||||
},
|
||||
},
|
||||
if (data) emit('ok');
|
||||
}
|
||||
</script>
|
||||
|
@ -1,70 +1,57 @@
|
||||
<template>
|
||||
<nav class="px-4 flex flex-col md:flex-row flex-wrap justify-between mt-0 py-1 bg-accent text-back dark:text-front">
|
||||
<nav class="px-4 flex flex-col md:flex-row flex-wrap justify-between mt-0 py-1 bg-primary/40">
|
||||
<div class="flex items-center">
|
||||
<i class="cursor-pointer glyph fa-bars fa-lg pt-1 mr-4 md:hidden" @click="collapsed = !collapsed"></i>
|
||||
|
||||
<i class="cursor-pointer fa fa-lg mr-2" :class="treeOpen ? 'fa-list-alt' : 'fa-list-ul'"
|
||||
@click="$emit('update:treeOpen', !treeOpen)"></i>
|
||||
<node-label oc="person" :dn="app.user" @select-dn="$emit('select-dn', $event)" class="text-lg" />
|
||||
@click="emit('update:treeOpen', !treeOpen)"></i>
|
||||
<node-label oc="person" :dn="user" @select-dn="emit('select-dn', $event)" class="text-lg" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4 text-lg" v-show="!collapsed">
|
||||
<!-- Right aligned nav items -->
|
||||
<span class="cursor-pointer" @click="$emit('show-modal', 'ldif-import')">Import…</span>
|
||||
<span class="cursor-pointer" @click="emit('show-modal', 'ldif-import')">Import…</span>
|
||||
|
||||
<dropdown-menu title="Schema">
|
||||
<li role="menuitem" v-for="obj in app.schema.ObjectClass.values"
|
||||
:key="obj.name" @click="app.oc = obj.name;">
|
||||
:key="obj.name" @click="emit('show-oc', obj.name)">
|
||||
{{ obj.name }}
|
||||
</li>
|
||||
</dropdown-menu>
|
||||
|
||||
<form @submit.prevent="search">
|
||||
<input class="glyph px-2 py-1 rounded border border-front/80 outline-none dark:bg-gray-800/80"
|
||||
autofocus :placeholder="' \uf002'" name="q" @focusin="$refs.q.select();"
|
||||
@keyup.esc="$refs.q.value = ''; query = '';" id="nav-search" ref="q" />
|
||||
<search-results for="nav-search" @select-dn="query = ''; $emit('select-dn', $event);"
|
||||
:shorten="this.app.baseDn" :query="query" />
|
||||
<input class="glyph px-2 py-1 rounded border border-front/80 outline-none text-front dark:bg-gray-800/80"
|
||||
autofocus :placeholder="' \uf002'" name="q" @focusin="input.select();" accesskey="k"
|
||||
@keyup.esc="query = '';" id="nav-search" ref="input" />
|
||||
<search-results for="nav-search" @select-dn="query = ''; emit('select-dn', $event);"
|
||||
:shorten="baseDn" :query="query" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { inject, nextTick, ref } from 'vue';
|
||||
import DropdownMenu from './ui/DropdownMenu.vue';
|
||||
import NodeLabel from './NodeLabel.vue';
|
||||
import SearchResults from './SearchResults.vue';
|
||||
|
||||
export default {
|
||||
name: 'NavBar',
|
||||
const
|
||||
app = inject('app'),
|
||||
input = ref(null),
|
||||
query = ref(''),
|
||||
collapsed = ref(false),
|
||||
emit = defineEmits(['select-dn', 'show-modal', 'show-oc']);
|
||||
|
||||
components: {
|
||||
DropdownMenu,
|
||||
NodeLabel,
|
||||
SearchResults,
|
||||
},
|
||||
defineProps({
|
||||
baseDn: String,
|
||||
treeOpen: Boolean,
|
||||
user: String,
|
||||
});
|
||||
|
||||
props: {
|
||||
dn: String,
|
||||
treeOpen: Boolean,
|
||||
},
|
||||
|
||||
inject: [ 'app' ],
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
query: '',
|
||||
collapsed: false,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
search: function() {
|
||||
const q = this.$refs.q.value;
|
||||
this.query = '';
|
||||
this.$nextTick(() => { this.query = q; });
|
||||
},
|
||||
},
|
||||
function search() {
|
||||
query.value = '';
|
||||
nextTick(() => { query.value = input.value.value; });
|
||||
}
|
||||
</script>
|
||||
|
@ -1,52 +1,41 @@
|
||||
<template>
|
||||
<span @click="$emit('select-dn', dn)" :title="dn"
|
||||
<span @click="emit('select-dn', dn)" :title="dn"
|
||||
class="node-label cursor-pointer select-none">
|
||||
<i class="fa w-6 text-center" :class="icon" v-if="oc"></i>
|
||||
<slot>{{ label }}</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'NodeLabel',
|
||||
|
||||
props: {
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
dn: String,
|
||||
oc: String,
|
||||
}),
|
||||
|
||||
icons = { // OC -> icon mapping
|
||||
account: 'user',
|
||||
groupOfNames: 'users',
|
||||
groupOfURLs: 'users',
|
||||
groupOfUniqueNames: 'users',
|
||||
inetOrgPerson: 'address-book',
|
||||
krbContainer: 'lock',
|
||||
krbPrincipal: 'user-o',
|
||||
krbRealmContainer: 'globe',
|
||||
organization: 'globe',
|
||||
organizationalRole: 'android',
|
||||
organizationalUnit: 'sitemap',
|
||||
person: 'user',
|
||||
posixGroup: 'users',
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
icons: { // OC -> icon mapping in tree
|
||||
account: 'user',
|
||||
groupOfNames: 'users',
|
||||
groupOfURLs: 'users',
|
||||
groupOfUniqueNames: 'users',
|
||||
inetOrgPerson: 'address-book',
|
||||
krbContainer: 'lock',
|
||||
krbPrincipal: 'user-o',
|
||||
krbRealmContainer: 'globe',
|
||||
organization: 'globe',
|
||||
organizationalRole: 'android',
|
||||
organizationalUnit: 'sitemap',
|
||||
person: 'user',
|
||||
posixGroup: 'users',
|
||||
}
|
||||
};
|
||||
},
|
||||
icon = computed(() => // Get the icon for an OC
|
||||
' fa-' + icons[props.oc] || 'question'),
|
||||
|
||||
computed: {
|
||||
// Shorten a DN for readability
|
||||
label = computed(() => (props.dn || '').split(',')[0]
|
||||
.replace(/^cn=/, '')
|
||||
.replace(/^krbPrincipalName=/, '')),
|
||||
|
||||
icon: function() { // Get the icon classes for a tree node
|
||||
return ' fa-' + this.icons[this.oc] || 'question';
|
||||
},
|
||||
|
||||
// Shorten a DN for readability
|
||||
label: function() {
|
||||
return (this.dn || '').split(',')[0]
|
||||
.replace(/^cn=/, '')
|
||||
.replace(/^krbPrincipalName=/, '');
|
||||
},
|
||||
},
|
||||
}
|
||||
emit = defineEmits(['select-dn']);
|
||||
</script>
|
||||
|
@ -7,17 +7,11 @@
|
||||
</popover>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue';
|
||||
import Popover from './ui/Popover.vue';
|
||||
|
||||
export default {
|
||||
name: 'SearchResults',
|
||||
|
||||
components: {
|
||||
Popover,
|
||||
},
|
||||
|
||||
props: {
|
||||
const props = defineProps({
|
||||
query: String,
|
||||
for: String,
|
||||
label: {
|
||||
@ -30,66 +24,49 @@
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
app = inject('app'),
|
||||
results = ref([]),
|
||||
show = computed(() => props.query.trim() != ''
|
||||
&& results.value && results.value.length > 1),
|
||||
emit = defineEmits(['select-dn']);
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
results: [],
|
||||
};
|
||||
},
|
||||
watch(() => props.query,
|
||||
async (q) => {
|
||||
if (!q) return;
|
||||
|
||||
inject: [ 'app' ],
|
||||
results.value = await app.xhr({ url: 'api/search/' + q });
|
||||
if (!results.value) return; // app.xhr failed
|
||||
|
||||
watch: {
|
||||
|
||||
query: async function(q) {
|
||||
if (!q) return;
|
||||
if (results.value.length == 0 && !props.silent) {
|
||||
app.showWarning('No search results');
|
||||
return;
|
||||
}
|
||||
|
||||
this.results = await this.app.xhr({ url: 'api/search/' + q });
|
||||
if (!this.results) return; // app.xhr failed
|
||||
if (results.value.length == 1) {
|
||||
done(results.value[0].dn);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.results.length == 0 && !this.silent) {
|
||||
this.app.showWarning('No search results');
|
||||
return;
|
||||
}
|
||||
results.value.sort((a, b) =>
|
||||
a[props.label].toLowerCase().localeCompare(
|
||||
b[props.label].toLowerCase()));
|
||||
});
|
||||
|
||||
if (this.results.length == 1) {
|
||||
this.done(this.results[0].dn);
|
||||
return;
|
||||
}
|
||||
function trim(dn) {
|
||||
return props.shorten && props.shorten != dn
|
||||
? dn.replace(props.shorten, '…') : dn;
|
||||
}
|
||||
|
||||
this.results.sort((a, b) =>
|
||||
a[this.label].toLowerCase().localeCompare(
|
||||
b[this.label].toLowerCase()));
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
trim: function(dn) {
|
||||
return this.shorten && this.shorten != dn
|
||||
? dn.replace(this.shorten, '…') : dn;
|
||||
},
|
||||
|
||||
// use an auto-completion choice
|
||||
done: function(dn) {
|
||||
this.$emit('select-dn', dn);
|
||||
this.results = [];
|
||||
|
||||
this.$nextTick(function() {
|
||||
// Return focus to search input
|
||||
const el = document.getElementById(this.for);
|
||||
if (el) el.focus();
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
show: function() {
|
||||
return this.query.trim() != ''
|
||||
&& this.results && this.results.length > 1;
|
||||
},
|
||||
}
|
||||
// use an auto-completion choice
|
||||
function done(dn) {
|
||||
emit('select-dn', dn);
|
||||
results.value = [];
|
||||
|
||||
nextTick(()=> {
|
||||
// Return focus to search input
|
||||
const el = document.getElementById(props.for);
|
||||
if (el) el.focus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
@ -19,7 +19,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { inject, onMounted, ref, watch } from 'vue';
|
||||
import NodeLabel from './NodeLabel.vue';
|
||||
|
||||
function Node(json) {
|
||||
@ -69,94 +70,79 @@
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'TreeView',
|
||||
|
||||
components: {
|
||||
NodeLabel,
|
||||
},
|
||||
|
||||
props: {
|
||||
const props = defineProps({
|
||||
activeDn: String,
|
||||
},
|
||||
}),
|
||||
app = inject('app'),
|
||||
tree = ref(null),
|
||||
emit = defineEmits(['base-dn', 'update:activeDn']);
|
||||
|
||||
inject: [ 'app' ],
|
||||
onMounted(async () => {
|
||||
await reload('base');
|
||||
emit('base-dn', tree.value.dn);
|
||||
});
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
tree: undefined,
|
||||
};
|
||||
},
|
||||
watch(() => props.activeDn, async (selected) => {
|
||||
if (!selected) return;
|
||||
|
||||
// Special case: Full tree reload
|
||||
if (selected == '-' || selected == 'base') {
|
||||
await reload('base');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all parents of the selected entry in the tree
|
||||
const dn = new app.schema.DN(selected || tree.value.dn);
|
||||
let hierarchy = [];
|
||||
for (let node = dn; node; node = node.parent) {
|
||||
hierarchy.push(node);
|
||||
if (node == tree.value.dn) break;
|
||||
}
|
||||
|
||||
created: async function() {
|
||||
await this.reload('base');
|
||||
this.$emit('base-dn', this.tree.dn);
|
||||
},
|
||||
// Reveal the selected entry by opening all parents
|
||||
hierarchy.reverse();
|
||||
for (let i = 0; i < hierarchy.length; ++i) {
|
||||
const p = hierarchy[i].toString(),
|
||||
node = tree.value.find(p);
|
||||
if (!node) break;
|
||||
if (!node.loaded) await reload(p);
|
||||
node.open = true;
|
||||
}
|
||||
|
||||
watch: {
|
||||
activeDn: async function(selected) {
|
||||
// Special case: Full tree reload
|
||||
if (selected == '-' || selected == 'base') {
|
||||
await this.reload('base');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all parents of the selected entry in the tree
|
||||
const dn = new this.app.schema.DN(selected || this.tree.dn);
|
||||
let hierarchy = [];
|
||||
for (let node = dn; node; node = node.parent) {
|
||||
hierarchy.push(node);
|
||||
if (node == this.tree.dn) break;
|
||||
}
|
||||
|
||||
// Reveal the selected entry by opening all parents
|
||||
hierarchy.reverse();
|
||||
for (let i = 0; i < hierarchy.length; ++i) {
|
||||
const p = hierarchy[i].toString(),
|
||||
node = this.tree.find(p);
|
||||
if (!node) break;
|
||||
if (!node.loaded) await this.reload(p);
|
||||
node.open = true;
|
||||
}
|
||||
|
||||
// Reload parent if entry was added, renamed or deleted
|
||||
if (!this.tree.find(dn.toString())) {
|
||||
await this.reload(dn.parent.toString());
|
||||
this.tree.find(dn.parent.toString()).open = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
// Reload parent if entry was added, renamed or deleted
|
||||
if (!tree.value.find(dn.toString())) {
|
||||
await reload(dn.parent.toString());
|
||||
tree.value.find(dn.parent.toString()).open = true;
|
||||
}
|
||||
});
|
||||
|
||||
methods: {
|
||||
clicked: async function(dn) {
|
||||
const item = this.tree.find(dn);
|
||||
if (item.hasSubordinates && !item.open) await this.toggle(item);
|
||||
this.$emit('update:activeDn', dn);
|
||||
},
|
||||
async function clicked(dn) {
|
||||
const item = tree.value.find(dn);
|
||||
if (item.hasSubordinates && !item.open) await toggle(item);
|
||||
emit('update:activeDn', dn);
|
||||
}
|
||||
|
||||
// Reload the subtree at entry with given DN
|
||||
reload: async function(dn) {
|
||||
const response = await this.app.xhr({ url: 'api/tree/' + dn }) || [];
|
||||
response.sort((a, b) => a.dn.toLowerCase().localeCompare(b.dn.toLowerCase()));
|
||||
async function reload(dn) {
|
||||
const response = await app.xhr({ url: 'api/tree/' + dn }) || [];
|
||||
response.sort((a, b) => a.dn.toLowerCase().localeCompare(b.dn.toLowerCase()));
|
||||
|
||||
if (dn == 'base') {
|
||||
this.tree = new Node(response[0]);
|
||||
await this.toggle(this.tree);
|
||||
return;
|
||||
}
|
||||
if (dn == 'base') {
|
||||
tree.value = new Node(response[0]);
|
||||
await toggle(tree.value);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = this.tree.find(dn);
|
||||
item.subordinates = response.map(node => new Node(node));
|
||||
item.hasSubordinates = item.subordinates.length > 0;
|
||||
return response;
|
||||
},
|
||||
const item = tree.value.find(dn);
|
||||
item.subordinates = response.map(node => new Node(node));
|
||||
item.hasSubordinates = item.subordinates.length > 0;
|
||||
return response;
|
||||
}
|
||||
|
||||
// Hide / show tree elements
|
||||
toggle: async function(item) {
|
||||
if (!item.open && !item.loaded) await this.reload(item.dn);
|
||||
item.open = !item.open;
|
||||
},
|
||||
},
|
||||
// Hide / show tree elements
|
||||
async function toggle(item) {
|
||||
if (!item.open && !item.loaded) await reload(item.dn);
|
||||
item.open = !item.open;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -1,62 +1,48 @@
|
||||
<template>
|
||||
<modal title="Add attribute" :open="modal == 'add-attribute'"
|
||||
@show="attr = null;" @shown="$refs.attr.focus()"
|
||||
@ok="onOk" @cancel="$emit('update:modal')">
|
||||
<modal title="Add attribute" :open="modal == 'add-attribute'" :return-to="props.returnTo"
|
||||
@show="attr = null;" @shown="select.focus()"
|
||||
@ok="onOk" @cancel="emit('update:modal')">
|
||||
|
||||
<select v-model="attr" ref="attr" @keyup.enter="onOk">
|
||||
<select v-model="attr" ref="select" @keyup.enter="onOk">
|
||||
<option v-for="attr in available" :key="attr">{{ attr }}</option>
|
||||
</select>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
export default {
|
||||
name: 'AddAttributeDialog',
|
||||
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
entry: Object,
|
||||
attributes: Array,
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
attributes: { type: Array, required: true },
|
||||
modal: String,
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
attr: null,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Add the selected attribute
|
||||
onOk: function() {
|
||||
if (!this.attr) return;
|
||||
|
||||
if (this.attr == 'jpegPhoto' || this.attr == 'thumbnailPhoto') {
|
||||
this.$emit('show-modal', 'add-' + this.attr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.attr == 'userPassword') {
|
||||
this.$emit('show-modal', 'change-password');
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('update:modal');
|
||||
this.$emit('ok', this.attr);
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
returnTo: String,
|
||||
}),
|
||||
attr = ref(null),
|
||||
select = ref(null),
|
||||
available = computed(() => {
|
||||
// Choice list for new attribute selection popup
|
||||
available: function() {
|
||||
const attrs = Object.keys(this.entry.attrs);
|
||||
return this.attributes.filter(attr => !attrs.includes(attr));
|
||||
},
|
||||
},
|
||||
const attrs = Object.keys(props.entry.attrs);
|
||||
return props.attributes.filter(attr => !attrs.includes(attr));
|
||||
}),
|
||||
emit = defineEmits(['ok', 'show-modal', 'update:modal']);
|
||||
|
||||
// Add the selected attribute
|
||||
function onOk() {
|
||||
if (!attr.value) return;
|
||||
|
||||
if (attr.value == 'jpegPhoto' || attr.value == 'thumbnailPhoto') {
|
||||
emit('show-modal', 'add-' + attr.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attr.value == 'userPassword') {
|
||||
emit('show-modal', 'change-password');
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:modal');
|
||||
emit('ok', attr.value);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,49 +1,34 @@
|
||||
<template>
|
||||
<modal title="Add objectClass" :open="modal == 'add-object-class'"
|
||||
@show="oc = null;" @shown="$refs.oc.focus()"
|
||||
@ok="onOk" @cancel="$emit('update:modal')">
|
||||
@show="oc = null;" @shown="select.focus()"
|
||||
@ok="onOk" @cancel="emit('update:modal')">
|
||||
|
||||
<select v-model="oc" ref="oc" @keyup.enter="onOk">
|
||||
<select v-model="oc" ref="select" @keyup.enter="onOk">
|
||||
<option v-for="cls in available" :key="cls">{{ cls }}</option>
|
||||
</select>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
export default {
|
||||
name: 'AddObjectClassDialog',
|
||||
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
entry: Object,
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
},
|
||||
}),
|
||||
oc = ref(null),
|
||||
select = ref(null),
|
||||
available = computed(() => {
|
||||
const classes = props.entry.attrs.objectClass;
|
||||
return props.entry.meta.aux.filter(cls => !classes.includes(cls));
|
||||
}),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
oc: null,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onOk: function() {
|
||||
if (this.oc) {
|
||||
this.$emit('update:modal');
|
||||
this.$emit('ok', this.oc);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
available: function() {
|
||||
const classes = this.entry.attrs.objectClass;
|
||||
return this.entry.meta.aux.filter(cls => !classes.includes(cls));
|
||||
},
|
||||
},
|
||||
function onOk() {
|
||||
if (oc.value) {
|
||||
emit('update:modal');
|
||||
emit('ok', oc.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,52 +1,46 @@
|
||||
<template>
|
||||
<modal title="Upload photo" hide-footer :open="modal == 'add-' + attr"
|
||||
@shown="$refs.upload.focus()" @cancel="$emit('update:modal')">
|
||||
<modal title="Upload photo" hide-footer :return-to="returnTo"
|
||||
:open="modal == 'add-' + attr"
|
||||
@shown="upload.focus()" @cancel="emit('update:modal')">
|
||||
|
||||
<input name="photo" type="file" ref="upload" @change="onOk"
|
||||
:accept="attr == 'jpegPhoto' ? 'image/jpeg' : 'image/*'" />
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
export default {
|
||||
name: 'AddPhotoDialog',
|
||||
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
dn: String,
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
attr: {
|
||||
type: String,
|
||||
validator: value => ['jpegPhoto', 'thumbnailPhoto' ].includes(value),
|
||||
validator: value => ['jpegPhoto', 'thumbnailPhoto'].includes(value),
|
||||
},
|
||||
modal: String,
|
||||
},
|
||||
returnTo: String,
|
||||
}),
|
||||
app = inject('app'),
|
||||
upload = ref('upload'),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
inject: [ 'app' ],
|
||||
// add an image
|
||||
async function onOk(evt) {
|
||||
if (!evt.target.files) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('blob', evt.target.files[0])
|
||||
const data = await app.xhr({
|
||||
url: 'api/blob/' + props.attr + '/0/' + props.dn,
|
||||
method: 'PUT',
|
||||
data: fd,
|
||||
binary: true,
|
||||
});
|
||||
|
||||
methods: {
|
||||
// add an image
|
||||
onOk: async function(evt) {
|
||||
if (!evt.target.files) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('blob', evt.target.files[0])
|
||||
const data = await this.app.xhr({
|
||||
url: 'api/blob/' + this.attr + '/0/' + this.dn,
|
||||
method: 'PUT',
|
||||
data: fd,
|
||||
binary: true,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
this.$emit('update:modal');
|
||||
this.$emit('ok', this.dn, data.changed);
|
||||
}
|
||||
},
|
||||
},
|
||||
if (data) {
|
||||
emit('update:modal');
|
||||
emit('ok', props.dn, data.changed);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -3,20 +3,20 @@
|
||||
<div :class="{ required: must, optional: may, rdn: isRdn, illegal: illegal }"
|
||||
class="w-1/4">
|
||||
<span class="cursor-pointer oc" :title="attr.desc"
|
||||
@click="app.attr = attr.name;">{{ attr }}</span>
|
||||
@click="emit('show-attr', attr.name)">{{ attr }}</span>
|
||||
<i v-if="changed" class="fa text-emerald-700 ml-1 fa-check"></i>
|
||||
</div>
|
||||
|
||||
<div class="w-3/4">
|
||||
<div v-for="(val, index) in values" :key="index">
|
||||
<span v-if="isStructural(val)" @click="$emit('show-modal', 'add-object-class')" tabindex="-1"
|
||||
<span v-if="isStructural(val)" @click="emit('show-modal', 'add-object-class')" tabindex="-1"
|
||||
class="add-btn control font-bold" title="Add object class…">⊕</span>
|
||||
<span v-else-if="isAux(val)" @click="removeObjectClass(index)"
|
||||
class="remove-btn control" :title="'Remove ' + val">⊖</span>
|
||||
<span v-else-if="password" class="fa fa-question-circle control"
|
||||
@click="$emit('show-modal', 'change-password')" tabindex="-1" title="change password"></span>
|
||||
@click="emit('show-modal', 'change-password')" tabindex="-1" title="change password"></span>
|
||||
<span v-else-if="attr == 'jpegPhoto' || attr == 'thumbnailPhoto'"
|
||||
@click="$emit('show-modal', 'add-jpegPhoto')" tabindex="-1"
|
||||
@click="emit('show-modal', 'add-jpegPhoto')" tabindex="-1"
|
||||
class="add-btn control align-top" title="Add photo…">⊕</span>
|
||||
<span v-else-if="multiple(index) && !illegal" @click="addRow"
|
||||
class="add-btn control" title="Add row">⊕</span>
|
||||
@ -29,7 +29,7 @@
|
||||
@click="deleteBlob(index)" title="Remove photo">⊖</span>
|
||||
</span>
|
||||
<input v-else :value="values[index]" :id="attr + '-' + index" :type="type"
|
||||
class="w-[90%] glyph outline-none bg-back border-x-0 border-t-0 border-b border-solid border-front/20 focus:border-accent px-1"
|
||||
class="w-[90%] glyph outline-none bg-back border-x-0 border-t-0 border-b border-solid border-front/20 focus:border-primary px-1"
|
||||
:class="{ structural: isStructural(val), auto: defaultValue, illegal: (illegal && !empty) || duplicate(index) }"
|
||||
:placeholder="placeholder" :disabled="disabled"
|
||||
:title="attr.equality == 'generalizedTimeMatch' ? dateString(val) : ''"
|
||||
@ -37,229 +37,204 @@
|
||||
@keyup="search" @keyup.esc="query = ''" />
|
||||
|
||||
<i v-if="attr == 'objectClass'" class="cursor-pointer fa fa-info-circle"
|
||||
@click="app.oc = val;"></i>
|
||||
@click="emit('show-oc', val)"></i>
|
||||
</div>
|
||||
<search-results silent v-if="completable && elementId" @select-dn="complete"
|
||||
:for="elementId" :query="query" label="dn" :shorten="app.baseDn" />
|
||||
:for="elementId" :query="query" label="dn" :shorten="baseDn" />
|
||||
<div v-if="hint" class="text-xs ml-6 opacity-70">{{ hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, inject, onMounted, onUpdated, ref, watch } from 'vue';
|
||||
import SearchResults from '../SearchResults.vue';
|
||||
|
||||
function unique(element, index, array) {
|
||||
return array.indexOf(element) == index;
|
||||
return element == '' || array.indexOf(element) == index;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'AttributeRow',
|
||||
const dateFormat = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric'
|
||||
},
|
||||
|
||||
idRanges = ['uidNumber', 'gidNumber'], // Numeric ID ranges
|
||||
|
||||
components: { SearchResults },
|
||||
|
||||
props: {
|
||||
props = defineProps({
|
||||
attr: { type: Object, required: true },
|
||||
baseDn: String,
|
||||
values: { type: Array, required: true },
|
||||
meta: { type: Object, required: true },
|
||||
must: { type: Boolean, required: true },
|
||||
may: { type: Boolean, required: true },
|
||||
changed: { type: Boolean, required: true },
|
||||
},
|
||||
}),
|
||||
|
||||
inject: [ 'app' ],
|
||||
app = inject('app'),
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
valid: undefined,
|
||||
valid = ref(true),
|
||||
|
||||
// Numeric ID ranges
|
||||
idRanges:
|
||||
[ 'uidNumber', 'gidNumber' ],
|
||||
// Range auto-completion
|
||||
autoFilled = ref(null),
|
||||
hint = ref(''),
|
||||
|
||||
// Range auto-completion
|
||||
autoFilled: null,
|
||||
hint: '',
|
||||
// DN search
|
||||
query = ref(''),
|
||||
elementId = ref(null),
|
||||
|
||||
// DN search
|
||||
query: '',
|
||||
elementId: undefined,
|
||||
};
|
||||
},
|
||||
completable = computed(() => props.attr.equality == 'distinguishedNameMatch'),
|
||||
defaultValue = computed(() => props.values.length == 1 && props.values[0] == autoFilled.value),
|
||||
empty = computed(() => props.values.every(value => !value.trim())),
|
||||
illegal = computed(() => !props.must && !props.may),
|
||||
isRdn = computed(() => props.attr.name == props.meta.dn.split('=')[0]),
|
||||
missing = computed(() => empty.value && props.must),
|
||||
password = computed(() => props.attr.name == 'userPassword'),
|
||||
|
||||
watch: {
|
||||
valid: function(ok) {
|
||||
this.$emit('valid', ok);
|
||||
}
|
||||
},
|
||||
binary = computed(() =>
|
||||
password.value ? false // Corner case with octetStringMatch
|
||||
: props.meta.binary.includes(props.attr.name)),
|
||||
|
||||
disabled = computed(() => isRdn.value
|
||||
|| props.attr.name == 'objectClass'
|
||||
|| (illegal.value && empty.value)
|
||||
|| (!props.meta.isNew && (password.value || binary.value))),
|
||||
|
||||
created: async function() {
|
||||
if (this.disabled
|
||||
|| !this.idRanges.includes(this.attr.name)
|
||||
|| this.values.length != 1
|
||||
|| this.values[0]) return;
|
||||
placeholder = computed(() => {
|
||||
let symbol = '';
|
||||
if (completable.value) symbol = ' \uf002 '; // fa-search
|
||||
if (missing.value) symbol = ' \uf071 '; // fa-warning
|
||||
if (empty.value) symbol = ' \uf1f8 '; // fa-trash
|
||||
return symbol;
|
||||
}),
|
||||
|
||||
const range = await this.app.xhr({ url: 'api/range/' + this.attr.name });
|
||||
if (!range) return;
|
||||
|
||||
this.hint = range.min == range.max
|
||||
? '> ' + range.min
|
||||
: '\u2209 (' + range.min + " - " + range.max + ')';
|
||||
this.autoFilled = new String(range.next);
|
||||
this.$emit('update', this.attr.name, [this.autoFilled], 0);
|
||||
},
|
||||
|
||||
mounted: function() { this.validate(); },
|
||||
updated: function() { this.validate(); },
|
||||
|
||||
methods: {
|
||||
validate: function() {
|
||||
this.valid = !this.missing
|
||||
&& (!this.illegal || this.empty)
|
||||
&& this.values.every(unique);
|
||||
},
|
||||
|
||||
update: function(evt) {
|
||||
const value = evt.target.value,
|
||||
index = +evt.target.id.split('-')[1];
|
||||
let values = this.values.slice();
|
||||
values[index] = value;
|
||||
this.$emit('update', this.attr.name, values);
|
||||
},
|
||||
|
||||
// Add an empty row in the entry form
|
||||
addRow: function() {
|
||||
let values = this.values.slice();
|
||||
if (!values.includes('')) values.push('');
|
||||
this.$emit('update', this.attr.name, values, values.length - 1);
|
||||
},
|
||||
|
||||
// Remove a row from the entry form
|
||||
removeObjectClass: function(index) {
|
||||
let values = this.values.slice(0, index).concat(this.values.slice(index + 1));
|
||||
this.$emit('update', 'objectClass', values);
|
||||
},
|
||||
|
||||
// human-readable dates
|
||||
dateString: function(dt) {
|
||||
let tz = dt.substr(14);
|
||||
if (tz != 'Z') {
|
||||
tz = tz.substr(0, 3) + ':'
|
||||
+ (tz.length > 3 ? tz.substring(3, 2) : '00');
|
||||
}
|
||||
return new Date(dt.substr(0, 4) + '-'
|
||||
+ dt.substr(4, 2) + '-'
|
||||
+ dt.substr(6, 2) + 'T'
|
||||
+ dt.substr(8, 2) + ':'
|
||||
+ dt.substr(10, 2) + ':'
|
||||
+ dt.substr(12, 2) + tz).toLocaleString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric'
|
||||
});
|
||||
},
|
||||
|
||||
// Is the given value a structural object class?
|
||||
isStructural: function(val) {
|
||||
return this.attr.name == 'objectClass' && this.app.schema.oc(val).structural;
|
||||
},
|
||||
|
||||
// Is the given value an auxillary object class?
|
||||
isAux: function(val) {
|
||||
return this.attr.name == 'objectClass' && !this.app.schema.oc(val).structural;
|
||||
},
|
||||
|
||||
duplicate: function(index) {
|
||||
return !unique(this.values[index], index, this.values);
|
||||
},
|
||||
|
||||
multiple: function(index) {
|
||||
return index == 0
|
||||
&& !this.attr.single_value
|
||||
&& !this.disabled
|
||||
&& !this.values.includes('');
|
||||
},
|
||||
|
||||
// auto-complete form values
|
||||
search: function(evt) {
|
||||
this.elementId = evt.target.id;
|
||||
const q = evt.target.value;
|
||||
this.query = q.length >= 2 && !q.includes(',') ? q : '';
|
||||
},
|
||||
|
||||
// use an auto-completion choice
|
||||
complete: function(dn) {
|
||||
const index = +this.elementId.split('-')[1];
|
||||
let values = this.values.slice();
|
||||
values[index] = dn;
|
||||
this.query = '';
|
||||
this.$emit('update', this.attr.name, values);
|
||||
},
|
||||
|
||||
// remove an image
|
||||
deleteBlob: async function(index) {
|
||||
const data = await this.app.xhr({
|
||||
method: 'DELETE',
|
||||
url: 'api/blob/' + this.attr.name + '/' + index + '/' + this.meta.dn,
|
||||
});
|
||||
|
||||
if (data) this.$emit('reload-form', this.meta.dn, data.changed);
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
shown: function() {
|
||||
return (this.attr.name == 'jpegPhoto' || this.attr.name == 'thumbnailPhoto')
|
||||
|| (!this.attr.no_user_mod && !this.binary);
|
||||
},
|
||||
|
||||
password: function() { return this.attr.name == 'userPassword'; },
|
||||
|
||||
binary: function() {
|
||||
return this.password ? false // Corner case with octetStringMatch
|
||||
: this.meta.binary.includes(this.attr.name);
|
||||
},
|
||||
|
||||
disabled: function() {
|
||||
return this.isRdn
|
||||
|| this.attr.name == 'objectClass'
|
||||
|| (this.illegal && this.empty)
|
||||
|| (!this.meta.isNew && (this.password || this.binary));
|
||||
},
|
||||
|
||||
completable: function() {
|
||||
return this.attr.equality == 'distinguishedNameMatch';
|
||||
},
|
||||
|
||||
placeholder: function() {
|
||||
if (this.completable) return ' \uf002'; // fa-search
|
||||
if (this.missing) return ' \uf071'; // fa-warning
|
||||
if (this.empty) return ' \uf1f8'; // fa-trash
|
||||
return undefined;
|
||||
},
|
||||
|
||||
isRdn: function() { return this.attr.name == this.meta.dn.split('=')[0]; },
|
||||
shown = computed(() =>
|
||||
props.attr.name == 'jpegPhoto'
|
||||
|| props.attr.name == 'thumbnailPhoto'
|
||||
|| (!props.attr.no_user_mod && !binary.value)),
|
||||
|
||||
type = computed(() => {
|
||||
// Guess the <input> type for an attribute
|
||||
type: function() {
|
||||
if (this.password) return 'password';
|
||||
if (this.attr.equality == 'integerMatch') return 'number';
|
||||
return 'text';
|
||||
},
|
||||
if (password.value) return 'password';
|
||||
return props.attr.equality == 'integerMatch' ? 'number' : 'text';
|
||||
}),
|
||||
|
||||
defaultValue: function() {
|
||||
return this.values.length == 1 && this.values[0] == this.autoFilled;
|
||||
},
|
||||
emit = defineEmits(['reload-form', 'show-attr', 'show-modal', 'show-oc', 'update', 'valid']);
|
||||
|
||||
empty: function() { return this.values.every(value => !value.trim()); },
|
||||
missing: function() { return this.empty && this.must; },
|
||||
illegal: function() { return !this.must && !this.may; }
|
||||
watch(valid, (ok) => emit('valid', ok));
|
||||
|
||||
onMounted(async () => {
|
||||
// Auto-fill ranges
|
||||
if (disabled.value
|
||||
|| !idRanges.includes(props.attr.name)
|
||||
|| props.values.length != 1
|
||||
|| props.values[0]) return;
|
||||
|
||||
},
|
||||
const range = await app.xhr({ url: 'api/range/' + props.attr.name });
|
||||
if (!range) return;
|
||||
|
||||
hint.value = range.min == range.max
|
||||
? '> ' + range.min
|
||||
: '\u2209 (' + range.min + " - " + range.max + ')';
|
||||
autoFilled.value = new String(range.next);
|
||||
emit('update', props.attr.name, [autoFilled.value], 0);
|
||||
validate();
|
||||
})
|
||||
|
||||
onUpdated(validate);
|
||||
|
||||
function validate() {
|
||||
valid.value = !missing.value
|
||||
&& (!illegal.value || empty.value)
|
||||
&& props.values.every(unique);
|
||||
}
|
||||
|
||||
function update(evt) {
|
||||
const value = evt.target.value,
|
||||
index = +evt.target.id.split('-')[1];
|
||||
let values = props.values.slice();
|
||||
values[index] = value;
|
||||
emit('update', props.attr.name, values);
|
||||
}
|
||||
|
||||
// Add an empty row in the entry form
|
||||
function addRow() {
|
||||
let values = props.values.slice();
|
||||
if (!values.includes('')) values.push('');
|
||||
emit('update', props.attr.name, values, values.length - 1);
|
||||
}
|
||||
|
||||
// Remove a row from the entry form
|
||||
function removeObjectClass(index) {
|
||||
let values = props.values.slice(0, index).concat(props.values.slice(index + 1));
|
||||
emit('update', 'objectClass', values);
|
||||
}
|
||||
|
||||
// human-readable dates
|
||||
function dateString(dt) {
|
||||
let tz = dt.substr(14);
|
||||
if (tz != 'Z') {
|
||||
tz = tz.substr(0, 3) + ':'
|
||||
+ (tz.length > 3 ? tz.substring(3, 2) : '00');
|
||||
}
|
||||
return new Date(dt.substr(0, 4) + '-'
|
||||
+ dt.substr(4, 2) + '-'
|
||||
+ dt.substr(6, 2) + 'T'
|
||||
+ dt.substr(8, 2) + ':'
|
||||
+ dt.substr(10, 2) + ':'
|
||||
+ dt.substr(12, 2) + tz).toLocaleString(undefined, dateFormat);
|
||||
}
|
||||
|
||||
// Is the given value a structural object class?
|
||||
function isStructural(val) {
|
||||
return props.attr.name == 'objectClass' && app.schema.oc(val).structural;
|
||||
}
|
||||
|
||||
// Is the given value an auxillary object class?
|
||||
function isAux(val) {
|
||||
return props.attr.name == 'objectClass' && !app.schema.oc(val).structural;
|
||||
}
|
||||
|
||||
function duplicate(index) {
|
||||
return !unique(props.values[index], index, props.values);
|
||||
}
|
||||
|
||||
function multiple(index) {
|
||||
return index == 0
|
||||
&& !props.attr.single_value
|
||||
&& !disabled.value
|
||||
&& !props.values.includes('');
|
||||
}
|
||||
|
||||
// auto-complete form values
|
||||
function search(evt) {
|
||||
elementId.value = evt.target.id;
|
||||
const q = evt.target.value;
|
||||
query.value = q.length >= 2 && !q.includes(',') ? q : '';
|
||||
}
|
||||
|
||||
// use an auto-completion choice
|
||||
function complete(dn) {
|
||||
const index = +elementId.value.split('-')[1];
|
||||
let values = props.values.slice();
|
||||
values[index] = dn;
|
||||
query.value = '';
|
||||
emit('update', props.attr.name, values);
|
||||
}
|
||||
|
||||
// remove an image
|
||||
async function deleteBlob(index) {
|
||||
const data = await app.xhr({
|
||||
method: 'DELETE',
|
||||
url: 'api/blob/' + props.attr.name + '/' + index + '/' + props.meta.dn,
|
||||
});
|
||||
|
||||
if (data) emit('reload-form', props.meta.dn, data.changed);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -285,7 +260,7 @@
|
||||
}
|
||||
|
||||
input.auto {
|
||||
@apply text-accent;
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
div.rdn span.oc::after {
|
||||
|
@ -1,67 +1,55 @@
|
||||
<template>
|
||||
<modal title="Copy entry" :open="modal == 'copy-entry'"
|
||||
@show="init" @shown="$refs.dn.focus()"
|
||||
@ok="onOk" @cancel="$emit('update:modal')">
|
||||
<modal title="Copy entry" :open="modal == 'copy-entry'" :return-to="returnTo"
|
||||
@show="init" @shown="newdn.focus()"
|
||||
@ok="onOk" @cancel="emit('update:modal')">
|
||||
|
||||
<div>
|
||||
<div class="text-danger text-xs mb-1" v-if="error">{{ error }}</div>
|
||||
<input ref="dn" v-model="dn" placeholder="New DN" @keyup.enter="onOk" />
|
||||
<input ref="newdn" v-model="dn" placeholder="New DN" @keyup.enter="onOk" />
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
export default {
|
||||
name: 'CopyEntryDialog',
|
||||
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
entry: Object,
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
},
|
||||
returnTo: String,
|
||||
}),
|
||||
dn = ref(null),
|
||||
error = ref(''),
|
||||
newdn = ref(null),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
dn: undefined,
|
||||
error: '',
|
||||
};
|
||||
},
|
||||
function init() {
|
||||
error.value = '';
|
||||
dn.value = props.entry.meta.dn;
|
||||
}
|
||||
|
||||
methods: {
|
||||
// Load copied entry into the editor
|
||||
function onOk() {
|
||||
if (!dn.value || dn.value== props.entry.meta.dn) {
|
||||
error.value = 'This DN already exists';
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = dn.value.split(','),
|
||||
rdnpart = parts[0].split('='),
|
||||
rdn = rdnpart[0];
|
||||
|
||||
init: function() {
|
||||
this.error = '';
|
||||
this.dn = this.entry.meta.dn;
|
||||
},
|
||||
if (rdnpart.length != 2) {
|
||||
error.value = 'Invalid RDN: ' + parts[0];
|
||||
return;
|
||||
}
|
||||
|
||||
// Load copied entry into the editor
|
||||
onOk: function() {
|
||||
if (!this.dn || this.dn == this.entry.meta.dn) {
|
||||
this.error = 'This DN already exists';
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = this.dn.split(','),
|
||||
rdnpart = parts[0].split('='),
|
||||
rdn = rdnpart[0];
|
||||
|
||||
if (rdnpart.length != 2) {
|
||||
this.error = 'Invalid RDN: ' + parts[0];
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('update:modal');
|
||||
const entry = JSON.parse(JSON.stringify(this.entry));
|
||||
entry.attrs[rdn] = [rdnpart[1]];
|
||||
entry.meta.dn = this.dn;
|
||||
entry.meta.isNew = true;
|
||||
this.$emit('ok', entry);
|
||||
},
|
||||
},
|
||||
emit('update:modal');
|
||||
const entry = JSON.parse(JSON.stringify(props.entry));
|
||||
entry.attrs[rdn] = [rdnpart[1]];
|
||||
entry.meta.dn = dn.value;
|
||||
entry.meta.isNew = true;
|
||||
emit('ok', entry);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modal title="Are you sure?" :open="modal == 'delete-entry'"
|
||||
cancel-variant="primary" ok-variant="danger"
|
||||
@show="init" @ok="onOk" @cancel="$emit('update:modal')">
|
||||
<modal title="Are you sure?" :open="modal == 'delete-entry'" :return-to="returnTo"
|
||||
cancel-classes="bg-primary/80" ok-classes="bg-danger/80"
|
||||
@show="init" @shown="onShown" @ok="onOk" @cancel="emit('update:modal')">
|
||||
|
||||
<p class="strong">This action is irreversible.</p>
|
||||
|
||||
@ -21,41 +21,31 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import NodeLabel from '../NodeLabel.vue';
|
||||
|
||||
export default {
|
||||
name: 'DeleteEntryDialog',
|
||||
|
||||
components: {
|
||||
Modal,
|
||||
NodeLabel,
|
||||
},
|
||||
|
||||
props: {
|
||||
dn: String,
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
modal: String,
|
||||
},
|
||||
returnTo: String,
|
||||
}),
|
||||
app = inject('app'),
|
||||
subtree = ref([]),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
inject: [ 'app' ],
|
||||
// List subordinate elements to be deleted
|
||||
async function init() {
|
||||
subtree.value = await app.xhr({ url: 'api/subtree/' + props.dn}) || [];
|
||||
}
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
subtree: [],
|
||||
};
|
||||
},
|
||||
function onShown() {
|
||||
document.getElementById('ui-modal-ok').focus();
|
||||
}
|
||||
|
||||
methods: {
|
||||
// List subordinate elements to be deleted
|
||||
init: async function() {
|
||||
this.subtree = await this.app.xhr({ url: 'api/subtree/' + this.dn}) || [];
|
||||
},
|
||||
|
||||
onOk: function() {
|
||||
this.$emit('update:modal');
|
||||
this.$emit('ok', this.dn);
|
||||
},
|
||||
},
|
||||
function onOk() {
|
||||
emit('update:modal');
|
||||
emit('ok', props.dn);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<modal title="Are you sure?" :open="modal == 'discard-entry'"
|
||||
cancel-variant="primary" ok-variant="danger"
|
||||
@show="next = dn;" @shown="$emit('shown')"
|
||||
@ok="onOk" @cancel="$emit('update:modal')">
|
||||
<modal title="Are you sure?" :open="modal == 'discard-entry'" :return-to="returnTo"
|
||||
cancel-classes="bg-primary/80" ok-classes="bg-danger/80"
|
||||
@show="next = dn;" @shown="onShown" @ok="onOk" @cancel="emit('update:modal')">
|
||||
|
||||
<p class="strong">All changes will be irreversibly lost.</p>
|
||||
|
||||
@ -12,32 +11,27 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
export default {
|
||||
name: 'DiscardEntryDialog',
|
||||
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
defineProps({
|
||||
dn: String,
|
||||
modal: String,
|
||||
},
|
||||
returnTo: String,
|
||||
});
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
next: undefined,
|
||||
};
|
||||
},
|
||||
const
|
||||
next = ref(null),
|
||||
emit = defineEmits(['ok', 'shown', 'update:modal']);
|
||||
|
||||
methods: {
|
||||
onOk: function() {
|
||||
this.$emit('update:modal');
|
||||
this.$emit('ok', this.next);
|
||||
},
|
||||
},
|
||||
function onShown() {
|
||||
document.getElementById('ui-modal-ok').focus();
|
||||
emit('shown');
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
emit('update:modal');
|
||||
emit('ok', next.value);
|
||||
}
|
||||
</script>
|
||||
|
@ -2,24 +2,31 @@
|
||||
<div v-if="entry" class="rounded border border-front/20 mb-3 mx-4 flex-auto">
|
||||
|
||||
<!-- Modals for navigation menu -->
|
||||
<new-entry-dialog v-model:modal="modal" :dn="entry.meta.dn" @ok="newEntry" />
|
||||
<copy-entry-dialog v-model:modal="modal" :entry="entry" @ok="newEntry" />
|
||||
<rename-entry-dialog v-model:modal="modal" :entry="entry" @ok="renameEntry" />
|
||||
<delete-entry-dialog v-model:modal="modal" :dn="entry.meta.dn" @ok="deleteEntry" />
|
||||
<discard-entry-dialog v-model:modal="modal" :dn="activeDn" @ok="discardEntry"
|
||||
@shown="$emit('update:activeDn')" />
|
||||
<new-entry-dialog v-model:modal="modal" :dn="entry.meta.dn" :return-to="focused" @ok="newEntry" />
|
||||
<copy-entry-dialog v-model:modal="modal" :entry="entry" :return-to="focused" @ok="newEntry" />
|
||||
<rename-entry-dialog v-model:modal="modal" :entry="entry" :return-to="focused" @ok="renameEntry" />
|
||||
<delete-entry-dialog v-model:modal="modal" :dn="entry.meta.dn" :return-to="focused" @ok="deleteEntry" />
|
||||
<discard-entry-dialog v-model:modal="modal" :dn="props.activeDn" :return-to="focused"
|
||||
@ok="discardEntry" @shown="emit('update:activeDn')" />
|
||||
|
||||
<!-- Modals for main editing area -->
|
||||
<password-change-dialog v-model:modal="modal" :entry="entry" @ok="changePassword" />
|
||||
<add-photo-dialog v-model:modal="modal" attr="jpegPhoto" :dn="entry.meta.dn" @ok="load" />
|
||||
<add-photo-dialog v-model:modal="modal" attr="thumbnailPhoto" :dn="entry.meta.dn" @ok="load" />
|
||||
<add-object-class-dialog v-model:modal="modal" :entry="entry" @ok="addObjectClass" />
|
||||
<password-change-dialog v-model:modal="modal"
|
||||
:entry="entry" :return-to="focused" :user="user"
|
||||
@ok="changePassword" />
|
||||
<add-photo-dialog v-model:modal="modal" attr="jpegPhoto"
|
||||
:dn="entry.meta.dn" :return-to="focused"
|
||||
@ok="load" />
|
||||
<add-photo-dialog v-model:modal="modal" attr="thumbnailPhoto"
|
||||
:dn="entry.meta.dn" :return-to="focused" @ok="load" />
|
||||
<add-object-class-dialog v-model:modal="modal"
|
||||
:entry="entry" :return-to="focused" @ok="addObjectClass" />
|
||||
|
||||
<!-- Modals for footer -->
|
||||
<add-attribute-dialog v-model:modal="modal" :entry="entry" :attributes="may"
|
||||
<add-attribute-dialog v-model:modal="modal"
|
||||
:entry="entry" :attributes="attributes('may')" :return-to="focused"
|
||||
@ok="addAttribute" @show-modal="modal = $event;" />
|
||||
|
||||
<nav class="flex justify-between mb-4 border-b border-front/20">
|
||||
<nav class="flex justify-between mb-4 border-b border-front/20 bg-primary/70">
|
||||
<div v-if="entry.meta.isNew" class="py-2 ml-3">
|
||||
<node-label :dn="entry.meta.dn" :oc="structural" />
|
||||
</div>
|
||||
@ -37,29 +44,32 @@
|
||||
</div>
|
||||
|
||||
<div v-if="entry.meta.isNew" class="control text-2xl mr-2"
|
||||
@click="modal = 'discard-entry';">⊗</div>
|
||||
<div v-else class="control text-xl mr-2" @click="$emit('update:activeDn')">⊗</div>
|
||||
@click="modal = 'discard-entry';" title="close">⊗</div>
|
||||
<div v-else class="control text-xl mr-2" title="close"
|
||||
@click="emit('update:activeDn')">⊗</div>
|
||||
</nav>
|
||||
|
||||
<form id="entry" class="space-y-4 my-4" @submit.prevent="save"
|
||||
@reset="load(entry.meta.dn)" @focusin="onFocus">
|
||||
<attribute-row v-for="key in keys" :key="key"
|
||||
<attribute-row v-for="key in keys" :key="key" :base-dn="props.baseDn"
|
||||
:attr="app.schema.attr(key)" :meta="entry.meta" :values="entry.attrs[key]"
|
||||
:changed="entry.changed.includes(key)"
|
||||
:may="may.includes(key)" :must="must.includes(key)"
|
||||
:may="attributes('may').includes(key)" :must="attributes('must').includes(key)"
|
||||
@update="updateRow"
|
||||
@reload-form="load"
|
||||
@valid="valid(key, $event)"
|
||||
@show-modal="modal = $event;" />
|
||||
@show-modal="modal = $event;"
|
||||
@show-attr="emit('show-attr', $event)"
|
||||
@show-oc="emit('show-oc', $event)" />
|
||||
|
||||
<!-- Footer with buttons -->
|
||||
<div class="flex ml-4 mt-2 space-x-4">
|
||||
<div class="w-1/4"></div>
|
||||
<div class="w-3/4 pl-4">
|
||||
<div class="w-[90%] space-x-3">
|
||||
<button type="submit" class="btn bg-primary"
|
||||
<button type="submit" class="btn bg-primary/70"
|
||||
accesskey="s" :disabled="invalid.length != 0">Submit</button>
|
||||
<button type="reset" v-if="!entry.meta.isNew"
|
||||
<button type="reset" v-if="!entry.meta.isNew" accesskey="r"
|
||||
class="btn bg-secondary">Reset</button>
|
||||
<button class="btn float-right bg-secondary" accesskey="a"
|
||||
v-if="!entry.meta.isNew" @click.prevent="modal = 'add-attribute';">
|
||||
@ -72,7 +82,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue';
|
||||
import AddAttributeDialog from './AddAttributeDialog.vue';
|
||||
import AddObjectClassDialog from './AddObjectClassDialog.vue';
|
||||
import AddPhotoDialog from './AddPhotoDialog.vue';
|
||||
@ -87,276 +98,228 @@
|
||||
import RenameEntryDialog from './RenameEntryDialog.vue';
|
||||
import { request } from '../../request.js';
|
||||
|
||||
|
||||
function unique(element, index, array) {
|
||||
return array.indexOf(element) == index;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'EntryEditor',
|
||||
const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'],
|
||||
|
||||
components: {
|
||||
AddAttributeDialog,
|
||||
AddObjectClassDialog,
|
||||
AddPhotoDialog,
|
||||
CopyEntryDialog,
|
||||
DeleteEntryDialog,
|
||||
DiscardEntryDialog,
|
||||
DropdownMenu,
|
||||
AttributeRow,
|
||||
NewEntryDialog,
|
||||
NodeLabel,
|
||||
PasswordChangeDialog,
|
||||
RenameEntryDialog,
|
||||
},
|
||||
|
||||
props: {
|
||||
props = defineProps({
|
||||
activeDn: String,
|
||||
},
|
||||
baseDn: String,
|
||||
user: String,
|
||||
}),
|
||||
|
||||
inject: [ 'app' ],
|
||||
app = inject('app'),
|
||||
entry = ref(null), // entry in editor
|
||||
focused = ref(null), // currently focused input
|
||||
invalid = ref([]), // field IDs with validation errors
|
||||
modal = ref(null), // pop-up dialog
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
entry: undefined, // entry in editor
|
||||
focused: undefined, // currently focused input
|
||||
invalid: [], // field IDs with validation errors
|
||||
modal: undefined, // pop-up dialog
|
||||
};
|
||||
},
|
||||
keys = computed(() => {
|
||||
let keys = Object.keys(entry.value.attrs);
|
||||
keys.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
return keys;
|
||||
}),
|
||||
|
||||
watch: {
|
||||
activeDn: function(dn) {
|
||||
if (!this.entry || dn != this.entry.meta.dn) this.focused = undefined;
|
||||
|
||||
if (dn && this.entry && this.entry.meta.isNew) {
|
||||
this.modal = 'discard-entry';
|
||||
}
|
||||
else if (dn) this.load(dn);
|
||||
else if (this.entry && !this.entry.meta.isNew) this.entry = null;
|
||||
structural = computed(() => {
|
||||
const oc = entry.value.attrs.objectClass
|
||||
.map(oc => app.schema.oc(oc))
|
||||
.filter(oc => oc && oc.structural)[0];
|
||||
return oc ? oc.name : '';
|
||||
}),
|
||||
|
||||
emit = defineEmits(['update:activeDn', 'show-attr', 'show-oc']);
|
||||
|
||||
watch(() => props.activeDn, (dn) => {
|
||||
if (!entry.value || dn != entry.value.meta.dn) focused.value = undefined;
|
||||
|
||||
if (dn && entry.value && entry.value.meta.isNew) {
|
||||
modal.value = 'discard-entry';
|
||||
}
|
||||
else if (dn) load(dn);
|
||||
else if (entry.value && !entry.value.meta.isNew) entry.value = null;
|
||||
});
|
||||
|
||||
function focus(focused) {
|
||||
nextTick(() => {
|
||||
const input = focused ? document.getElementById(focused)
|
||||
: document.querySelector('form#entry input:not([disabled])');
|
||||
|
||||
if (input) {
|
||||
// work around annoying focus jump in OS X Safari
|
||||
window.setTimeout(() => input.focus(), 100);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
methods: {
|
||||
// Track focus changes
|
||||
onFocus: function(evt) {
|
||||
const el = evt.target;
|
||||
if (el.tagName == 'INPUT' && el.id) this.focused = el.id;
|
||||
},
|
||||
// Track focus changes
|
||||
function onFocus(evt) {
|
||||
const el = evt.target;
|
||||
if (el.id && inputTags.includes(el.tagName)) focused.value = el.id;
|
||||
}
|
||||
|
||||
newEntry: function(entry) {
|
||||
this.entry = entry;
|
||||
this.$emit('update:activeDn');
|
||||
this.prepareForm();
|
||||
},
|
||||
function newEntry(newEntry) {
|
||||
entry.value = newEntry;
|
||||
emit('update:activeDn');
|
||||
focus(addMandatoryRows());
|
||||
}
|
||||
|
||||
discardEntry: function(dn) {
|
||||
this.entry = null;
|
||||
this.$emit('update:activeDn', dn);
|
||||
},
|
||||
function discardEntry(dn) {
|
||||
entry.value = null;
|
||||
emit('update:activeDn', dn);
|
||||
}
|
||||
|
||||
addAttribute: function(attr) {
|
||||
if (attr) { // FIXME: Why is this called with attr=undefined?
|
||||
this.entry.attrs[attr] = [''];
|
||||
this.prepareForm(attr + '-0');
|
||||
}
|
||||
},
|
||||
function addAttribute(attr) {
|
||||
entry.value.attrs[attr] = [''];
|
||||
focus(attr + '-0');
|
||||
}
|
||||
|
||||
addObjectClass: function(oc) {
|
||||
if (oc) { // FIXME: Why is this called with oc=undefined?
|
||||
this.entry.attrs.objectClass.push(oc);
|
||||
const aux = this.entry.meta.aux.filter(oc => oc < oc);
|
||||
this.entry.meta.aux.splice(aux.length, 1);
|
||||
this.prepareForm();
|
||||
}
|
||||
},
|
||||
function addObjectClass(oc) {
|
||||
entry.value.attrs.objectClass.push(oc);
|
||||
const aux = entry.value.meta.aux.filter(oc => oc < oc);
|
||||
entry.value.meta.aux.splice(aux.length, 1);
|
||||
focus(addMandatoryRows() || focused.value);
|
||||
}
|
||||
|
||||
// Remove a row from the entry form
|
||||
removeObjectClass: function(newOcs) {
|
||||
const removedOc = this.entry.attrs.objectClass.filter(
|
||||
oc => !newOcs.includes(oc))[0];
|
||||
if (removedOc) {
|
||||
const aux = this.entry.meta.aux.filter(oc => oc < removedOc);
|
||||
this.entry.meta.aux.splice(aux.length, 0, removedOc);
|
||||
}
|
||||
},
|
||||
// Remove a row from the entry form
|
||||
function removeObjectClass(newOcs) {
|
||||
const removedOc = entry.value.attrs.objectClass.filter(
|
||||
oc => !newOcs.includes(oc))[0];
|
||||
if (removedOc) {
|
||||
const aux = entry.value.meta.aux.filter(oc => oc < removedOc);
|
||||
entry.value.meta.aux.splice(aux.length, 0, removedOc);
|
||||
}
|
||||
}
|
||||
|
||||
updateRow: function(attr, values, index) {
|
||||
this.entry.attrs[attr] = values;
|
||||
if (attr == 'objectClass') this.removeObjectClass(values);
|
||||
const focused = index != undefined ? attr + '-' + index : undefined;
|
||||
this.prepareForm(focused);
|
||||
},
|
||||
function updateRow(attr, values, index) {
|
||||
entry.value.attrs[attr] = values;
|
||||
if (attr == 'objectClass') {
|
||||
removeObjectClass(values);
|
||||
focus(focused.value);
|
||||
}
|
||||
if (index !== undefined) focus(attr + '-' + index);
|
||||
}
|
||||
|
||||
prepareForm: function(focused) {
|
||||
this.must.filter(attr => !this.entry.attrs[attr])
|
||||
.forEach(attr => this.entry.attrs[attr] = ['']);
|
||||
function addMandatoryRows() {
|
||||
const must = attributes('must')
|
||||
.filter(attr => !entry.value.attrs[attr]);
|
||||
must.forEach(attr => entry.value.attrs[attr] = ['']);
|
||||
return must.length ? must[0] + '-0' : undefined;
|
||||
}
|
||||
|
||||
if (!focused) {
|
||||
const empty = this.keys.flatMap(attr => this.entry.attrs[attr]
|
||||
.map((value, index) => value.trim() ? undefined : attr + '-' + index)
|
||||
.filter(id => id));
|
||||
focused = empty[0];
|
||||
}
|
||||
// Load an entry into the editing form
|
||||
async function load(dn, changed, focused) {
|
||||
invalid.value = [];
|
||||
|
||||
if (!focused) focused = this.focused;
|
||||
if (!dn || dn.startsWith('-')) {
|
||||
entry.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.$nextTick(function () {
|
||||
let input = focused ? document.getElementById(focused) : undefined;
|
||||
if (!input) input = document.querySelector('form#entry input:not([disabled])');
|
||||
|
||||
if (input) {
|
||||
// work around annoying focus jump in OS X Safari
|
||||
window.setTimeout(() => input.focus(), 100);
|
||||
this.focused = input.id;
|
||||
}
|
||||
});
|
||||
},
|
||||
entry.value = await app.xhr({ url: 'api/entry/' + dn });
|
||||
if (!entry.value) return;
|
||||
|
||||
// Load an entry into the editing form
|
||||
load: async function(dn, changed) {
|
||||
this.invalid = [];
|
||||
entry.value.changed = changed || [];
|
||||
entry.value.meta.isNew = false;
|
||||
|
||||
if (!dn || dn.startsWith('-')) {
|
||||
this.entry = null;
|
||||
return;
|
||||
}
|
||||
document.title = dn.split(',')[0];
|
||||
focus(focused);
|
||||
}
|
||||
|
||||
this.entry = await this.app.xhr({ url: 'api/entry/' + dn });
|
||||
if (!this.entry) return;
|
||||
// Submit the entry form via AJAX
|
||||
async function save() {
|
||||
if (invalid.value.length > 0) {
|
||||
focus(focused.value);
|
||||
return;
|
||||
}
|
||||
|
||||
this.entry.changed = changed || [];
|
||||
this.entry.meta.isNew = false;
|
||||
entry.value.changed = [];
|
||||
const data = await app.xhr({
|
||||
url: 'api/entry/' + entry.value.meta.dn,
|
||||
method: entry.value.meta.isNew ? 'PUT' : 'POST',
|
||||
data: JSON.stringify(entry.value.attrs),
|
||||
});
|
||||
|
||||
document.title = dn.split(',')[0];
|
||||
this.prepareForm();
|
||||
},
|
||||
if (!data) return;
|
||||
|
||||
if (data.changed && data.changed.length) {
|
||||
app.showInfo('👍 Saved changes');
|
||||
}
|
||||
if (entry.value.meta.isNew) {
|
||||
entry.value.meta.isNew = false;
|
||||
emit('update:activeDn', entry.value.meta.dn);
|
||||
}
|
||||
else load(entry.value.meta.dn, data.changed, focused.value);
|
||||
}
|
||||
|
||||
// Submit the entry form via AJAX
|
||||
save: async function() {
|
||||
if (this.invalid.length > 0) {
|
||||
this.prepareForm();
|
||||
return;
|
||||
}
|
||||
async function renameEntry(rdn) {
|
||||
await app.xhr({
|
||||
url: 'api/rename',
|
||||
method: 'POST',
|
||||
data: JSON.stringify({
|
||||
dn: entry.value.meta.dn,
|
||||
rdn: rdn
|
||||
}),
|
||||
});
|
||||
|
||||
this.entry.changed = [];
|
||||
const data = await this.app.xhr({
|
||||
url: 'api/entry/' + this.entry.meta.dn,
|
||||
method: this.entry.meta.isNew ? 'PUT' : 'POST',
|
||||
data: JSON.stringify(this.entry.attrs),
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
const dnparts = entry.value.meta.dn.split(',');
|
||||
dnparts.splice(0, 1, rdn);
|
||||
emit('update:activeDn', dnparts.join(','));
|
||||
}
|
||||
|
||||
if (!data) return;
|
||||
|
||||
if (data.changed && data.changed.length) {
|
||||
this.app.showInfo('👍 Saved changes');
|
||||
}
|
||||
if (this.entry.meta.isNew) {
|
||||
this.entry.meta.isNew = false;
|
||||
this.$emit('update:activeDn', this.entry.meta.dn);
|
||||
}
|
||||
else this.load(this.entry.meta.dn, data.changed);
|
||||
},
|
||||
async function deleteEntry(dn) {
|
||||
if (await app.xhr({ url: 'api/entry/' + dn, method: 'DELETE' }) !== undefined) {
|
||||
app.showInfo('Deleted: ' + dn);
|
||||
emit('update:activeDn', '-' + dn);
|
||||
}
|
||||
}
|
||||
|
||||
renameEntry: async function(rdn) {
|
||||
if (rdn) { // FIXME: Why is this called with rdn=undefined?
|
||||
await this.app.xhr({
|
||||
url: 'api/rename',
|
||||
method: 'POST',
|
||||
data: JSON.stringify({
|
||||
dn: this.entry.meta.dn,
|
||||
rdn: rdn
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
async function changePassword(oldPass, newPass) {
|
||||
const data = await app.xhr({
|
||||
url: 'api/entry/password/' + entry.value.meta.dn,
|
||||
method: 'POST',
|
||||
data: JSON.stringify({ old: oldPass, new1: newPass }),
|
||||
});
|
||||
|
||||
const dnparts = this.entry.meta.dn.split(',');
|
||||
dnparts.splice(0, 1, rdn);
|
||||
this.$emit('update:activeDn', dnparts.join(','));
|
||||
}
|
||||
},
|
||||
if (data !== undefined) {
|
||||
entry.value.attrs.userPassword = [ data ];
|
||||
entry.value.changed.push('userPassword');
|
||||
}
|
||||
}
|
||||
|
||||
deleteEntry: async function(dn) {
|
||||
if (dn) { // FIXME: Why is this called with dn=undefined?
|
||||
if (await this.app.xhr({ url: 'api/entry/' + dn, method: 'DELETE' }) !== undefined) {
|
||||
this.app.showInfo('Deleted: ' + dn);
|
||||
this.$emit('update:activeDn', '-' + dn);
|
||||
}
|
||||
}
|
||||
},
|
||||
// Download LDIF
|
||||
async function ldif() {
|
||||
const data = await request({
|
||||
url: 'api/ldif/' + entry.value.meta.dn,
|
||||
responseType: 'blob' });
|
||||
if (!data) return;
|
||||
|
||||
changePassword: async function(oldPass, newPass) {
|
||||
const data = await this.app.xhr({
|
||||
url: 'api/entry/password/' + this.entry.meta.dn,
|
||||
method: 'POST',
|
||||
data: JSON.stringify({ old: oldPass, new1: newPass }),
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
const a = document.createElement("a"),
|
||||
url = URL.createObjectURL(data.response);
|
||||
a.href = url;
|
||||
a.download = entry.value.meta.dn.split(',')[0].split('=')[1] + '.ldif';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}
|
||||
|
||||
function attributes(kind) {
|
||||
let attrs = entry.value.attrs.objectClass
|
||||
.filter(oc => oc && oc != 'top')
|
||||
.map(oc => app.schema.oc(oc))
|
||||
.flatMap(oc => oc ? oc.$collect(kind): [])
|
||||
.filter(unique);
|
||||
attrs.sort();
|
||||
return attrs;
|
||||
}
|
||||
|
||||
if (data !== undefined) {
|
||||
this.entry.attrs.userPassword = [ data ];
|
||||
this.entry.changed.push('userPassword');
|
||||
}
|
||||
},
|
||||
|
||||
// Download LDIF
|
||||
ldif: async function() {
|
||||
const data = await request({
|
||||
url: 'api/ldif/' + this.entry.meta.dn,
|
||||
responseType: 'blob' });
|
||||
if (!data) return;
|
||||
|
||||
const a = document.createElement("a"),
|
||||
url = URL.createObjectURL(data.response);
|
||||
a.href = url;
|
||||
a.download = this.entry.meta.dn.split(',')[0].split('=')[1] + '.ldif';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
},
|
||||
|
||||
attributes: function(kind) {
|
||||
let attrs = this.entry.attrs.objectClass
|
||||
.filter(oc => oc && oc != 'top')
|
||||
.map(oc => this.app.schema.oc(oc))
|
||||
.flatMap(oc => oc ? oc.$collect(kind): [])
|
||||
.filter(unique);
|
||||
attrs.sort();
|
||||
return attrs;
|
||||
},
|
||||
|
||||
valid: function(key, valid) {
|
||||
if (valid) {
|
||||
const pos = this.invalid.indexOf(key);
|
||||
if (pos >= 0) this.invalid.splice(pos, 1);
|
||||
}
|
||||
else if (!valid && !this.invalid.includes(key)) {
|
||||
this.invalid.push(key);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
keys: function() {
|
||||
let keys = Object.keys(this.entry.attrs);
|
||||
keys.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
return keys;
|
||||
},
|
||||
|
||||
structural: function() {
|
||||
const oc = this.entry.attrs.objectClass
|
||||
.map(oc => this.app.schema.oc(oc))
|
||||
.filter(oc => oc && oc.structural)[0];
|
||||
return oc ? oc.name : '';
|
||||
},
|
||||
|
||||
must: function() {
|
||||
return this.attributes('must');
|
||||
},
|
||||
|
||||
may: function() {
|
||||
return this.attributes('may');
|
||||
},
|
||||
},
|
||||
function valid(key, valid) {
|
||||
if (valid) {
|
||||
const pos = invalid.value.indexOf(key);
|
||||
if (pos >= 0) invalid.value.splice(pos, 1);
|
||||
}
|
||||
else if (!invalid.value.includes(key)) {
|
||||
invalid.value.push(key);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<modal title="New entry" :open="modal == 'new-entry'"
|
||||
@ok="onOk" @cancel="$emit('update:modal')"
|
||||
@show="init" @shown="$refs.oc.focus()">
|
||||
<modal title="New entry" :open="modal == 'new-entry'" :return-to="returnTo"
|
||||
@ok="onOk" @cancel="emit('update:modal')"
|
||||
@show="init" @shown="select.focus()">
|
||||
|
||||
<label>Object class:
|
||||
<select ref="oc" v-model="objectClass">
|
||||
<select ref="select" v-model="objectClass">
|
||||
<template v-for="cls in app.schema.ObjectClass.values" :key="cls.name">
|
||||
<option v-if="cls.structural">{{ cls }}</option>
|
||||
</template>
|
||||
@ -24,85 +24,64 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
export default {
|
||||
name: 'NewEntryDialog',
|
||||
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
dn: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
modal: String,
|
||||
},
|
||||
returnTo: String,
|
||||
}),
|
||||
app = inject('app'),
|
||||
objectClass = ref(null),
|
||||
rdn = ref(null),
|
||||
name = ref(null),
|
||||
select = ref(null),
|
||||
oc = computed(() => app.schema.oc(objectClass.value)),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
inject: [ 'app' ],
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
objectClass: null,
|
||||
rdn: null,
|
||||
name: null,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
init: function() {
|
||||
this.objectClass = this.rdn = this.name = null;
|
||||
},
|
||||
function init() {
|
||||
objectClass.value = rdn.value = name.value = null;
|
||||
}
|
||||
|
||||
// Create a new entry in the main editor
|
||||
onOk: function() {
|
||||
if (!this.objectClass || !this.rdn || !this.name) return;
|
||||
function onOk() {
|
||||
if (!objectClass.value || !rdn.value || !name.value) return;
|
||||
|
||||
this.$emit('update:modal');
|
||||
emit('update:modal');
|
||||
|
||||
const objectClasses = [this.objectClass];
|
||||
for (let oc = this.oc.$super; oc; oc = oc.$super) {
|
||||
if (!oc.structural && oc.kind != 'abstract') {
|
||||
objectClasses.push(oc.name);
|
||||
}
|
||||
}
|
||||
|
||||
const entry = {
|
||||
meta: {
|
||||
dn: this.rdn + '=' + this.name + ',' + this.dn,
|
||||
aux: [],
|
||||
required: [],
|
||||
binary: [],
|
||||
hints: {},
|
||||
autoFilled: [],
|
||||
isNew: true,
|
||||
},
|
||||
attrs: {
|
||||
objectClass: objectClasses,
|
||||
},
|
||||
changed: [],
|
||||
};
|
||||
entry.attrs[this.rdn] = [this.name];
|
||||
this.$emit('ok', entry);
|
||||
const objectClasses = [objectClass.value];
|
||||
for (let o = oc.value.$super; o; o = o.$super) {
|
||||
if (!o.structural && o.kind != 'abstract') {
|
||||
objectClasses.push(o.name);
|
||||
}
|
||||
}
|
||||
|
||||
const entry = {
|
||||
meta: {
|
||||
dn: rdn.value + '=' + name.value + ',' + props.dn,
|
||||
aux: [],
|
||||
required: [],
|
||||
binary: [],
|
||||
hints: {},
|
||||
autoFilled: [],
|
||||
isNew: true,
|
||||
},
|
||||
|
||||
// Choice list of RDN attributes for a new entry
|
||||
rdns: function() {
|
||||
if (!this.objectClass) return [];
|
||||
const ocs = this.oc.$collect('must');
|
||||
if (ocs.length == 1) this.rdn = ocs[0];
|
||||
return ocs;
|
||||
attrs: {
|
||||
objectClass: objectClasses,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
oc: function() {
|
||||
return this.app.schema.oc(this.objectClass);
|
||||
},
|
||||
},
|
||||
changed: [],
|
||||
};
|
||||
entry.attrs[rdn.value] = [name.value];
|
||||
emit('ok', entry);
|
||||
}
|
||||
|
||||
// Choice list of RDN attributes for a new entry
|
||||
function rdns() {
|
||||
if (!objectClass.value) return [];
|
||||
const ocs = oc.value.$collect('must');
|
||||
if (ocs.length == 1) rdn.value = ocs[0];
|
||||
return ocs;
|
||||
}
|
||||
</script>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modal title="Change / verify password" :open="modal == 'change-password'"
|
||||
<modal title="Change / verify password" :open="modal == 'change-password'" :return-to="returnTo"
|
||||
@show="init" @shown="focus" @ok="onOk"
|
||||
@cancel="$emit('update:modal')" @hidden="$emit('update-form')">
|
||||
@cancel="emit('update:modal')" @hidden="emit('update-form')">
|
||||
|
||||
<div v-if="oldExists">
|
||||
<small >{{ currentUser ? 'Required' : 'Optional' }}</small>
|
||||
@ -19,85 +19,66 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
export default {
|
||||
name: 'PasswordChangeDialog',
|
||||
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
entry: Object,
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
},
|
||||
returnTo: String,
|
||||
user: String,
|
||||
}),
|
||||
|
||||
inject: [ 'app' ],
|
||||
app = inject('app'),
|
||||
oldPassword = ref(''),
|
||||
newPassword = ref(''),
|
||||
repeated = ref(''),
|
||||
passwordOk = ref(),
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
repeated: '',
|
||||
passwordOk: undefined,
|
||||
};
|
||||
},
|
||||
old = ref(null),
|
||||
changed = ref(null),
|
||||
|
||||
methods: {
|
||||
init: function() {
|
||||
this.oldPassword = this.newPassword = this.repeated = '';
|
||||
this.passwordOk = undefined;
|
||||
},
|
||||
currentUser = computed(() => props.user == props.entry.meta.dn),
|
||||
passwordsMatch = computed(() => newPassword.value && newPassword.value == repeated.value),
|
||||
oldExists = computed(() => props.entry.attrs.userPassword
|
||||
&& props.entry.attrs.userPassword[0] != ''),
|
||||
|
||||
focus: function() {
|
||||
if (this.oldExists) this.$refs.old.focus();
|
||||
else this.$refs.changed.focus();
|
||||
},
|
||||
emit = defineEmits(['ok', 'update-form', 'update:modal']);
|
||||
|
||||
// Verify an existing password
|
||||
// This is optional for administrative changes
|
||||
// but required to change the current user's password
|
||||
check: async function() {
|
||||
if (!this.oldPassword || this.oldPassword.length == 0) {
|
||||
this.passwordOk = undefined;
|
||||
return;
|
||||
}
|
||||
this.passwordOk = await this.app.xhr({
|
||||
url: 'api/entry/password/' + this.entry.meta.dn,
|
||||
method: 'POST',
|
||||
data: JSON.stringify({ check: this.oldPassword }),
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
},
|
||||
function init() {
|
||||
oldPassword.value = newPassword.value = repeated.value = '';
|
||||
passwordOk.value = undefined;
|
||||
}
|
||||
|
||||
onOk: async function() {
|
||||
// old and new passwords are required for current user
|
||||
// new passwords must match
|
||||
if ((this.currentUser && !this.newPassword)
|
||||
|| this.newPassword != this.repeated
|
||||
|| (this.currentUser && this.oldExists && !this.passwordOk)) return;
|
||||
function focus() {
|
||||
if (oldExists.value) old.value.focus();
|
||||
else changed.value.focus();
|
||||
}
|
||||
|
||||
this.$emit('update:modal');
|
||||
this.$emit('ok', this.oldPassword, this.newPassword);
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
currentUser: function() {
|
||||
return this.app.user == this.entry.meta.dn;
|
||||
},
|
||||
|
||||
// Verify that the new password is repeated correctly
|
||||
passwordsMatch: function() {
|
||||
return this.newPassword && this.newPassword == this.repeated;
|
||||
},
|
||||
|
||||
oldExists: function() {
|
||||
return this.entry.attrs.userPassword
|
||||
&& this.entry.attrs.userPassword[0] != '';
|
||||
},
|
||||
// Verify an existing password
|
||||
// This is optional for administrative changes
|
||||
// but required to change the current user's password
|
||||
async function check() {
|
||||
if (!oldPassword.value || oldPassword.value.length == 0) {
|
||||
passwordOk.value = undefined;
|
||||
return;
|
||||
}
|
||||
passwordOk.value = await app.xhr({
|
||||
url: 'api/entry/password/' + props.entry.meta.dn,
|
||||
method: 'POST',
|
||||
data: JSON.stringify({ check: oldPassword.value }),
|
||||
});
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
// old and new passwords are required for current user
|
||||
// new passwords must match
|
||||
if ((currentUser.value && !newPassword.value)
|
||||
|| newPassword.value != repeated.value
|
||||
|| (currentUser.value && oldExists.value && !passwordOk.value)) return;
|
||||
|
||||
emit('update:modal');
|
||||
emit('ok', oldPassword.value, newPassword.value);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,64 +1,45 @@
|
||||
<template>
|
||||
<modal title="Rename entry" :open="modal == 'rename-entry'"
|
||||
@ok="onOk" @cancel="$emit('update:modal')"
|
||||
@show="init" @shown="$refs.rdn.focus()">
|
||||
<modal title="Rename entry" :open="modal == 'rename-entry'" :return-to="returnTo"
|
||||
@ok="onOk" @cancel="emit('update:modal')"
|
||||
@show="init" @shown="select.focus()">
|
||||
|
||||
<label>New RDN attribute:
|
||||
<select ref="rdn" v-model="rdn" @keyup.enter="onOk">
|
||||
<select ref="select" v-model="rdn" @keyup.enter="onOk">
|
||||
<option v-for="rdn in rdns" :key="rdn">{{ rdn }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
export default {
|
||||
name: 'RenameEntryDialog',
|
||||
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
entry: Object,
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
},
|
||||
returnTo: String,
|
||||
}),
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
rdn: undefined,
|
||||
};
|
||||
},
|
||||
rdn = ref(),
|
||||
select = ref(null),
|
||||
rdns = computed(() => Object.keys(props.entry.attrs).filter(ok)),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
methods: {
|
||||
init: function() {
|
||||
this.rdn = this.rdns.length == 1 ? this.rdns[0] : undefined;
|
||||
},
|
||||
function init() {
|
||||
rdn.value = rdns.value.length == 1 ? rdns.value[0] : undefined;
|
||||
}
|
||||
|
||||
onOk: async function() {
|
||||
const rdnAttr = this.entry.attrs[this.rdn];
|
||||
if (!rdnAttr || !rdnAttr[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('update:modal');
|
||||
const rdn = this.rdn + '=' + rdnAttr[0];
|
||||
this.$emit('ok', rdn);
|
||||
},
|
||||
|
||||
ok: function(key) {
|
||||
const rdn = this.entry.meta.dn.split('=')[0];
|
||||
return key != rdn && !this.entry.attrs[key].every(val => !val);
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
rdns: function() {
|
||||
return Object.keys(this.entry.attrs).filter(a => this.ok(a));
|
||||
},
|
||||
|
||||
function onOk() {
|
||||
const rdnAttr = props.entry.attrs[rdn.value];
|
||||
if (rdnAttr && rdnAttr[0]) {
|
||||
emit('update:modal');
|
||||
emit('ok', rdn.value + '=' + rdnAttr[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function ok(key) {
|
||||
const rdn = props.entry.meta.dn.split('=')[0];
|
||||
return key != rdn && !props.entry.attrs[key].every(val => !val);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<card v-if="modelValue" :title="attr.names.join(', ')" @close="$emit('update:modelValue')">
|
||||
<card v-if="modelValue" :title="attr.names.join(', ')" @close="emit('update:modelValue')">
|
||||
|
||||
<div class="header">{{ attr.desc }}</div>
|
||||
|
||||
<ul class="list-disc mt-2">
|
||||
<li v-if="attr.$super">Parent:
|
||||
<span class="cursor-pointer"
|
||||
@click="$emit('update:modelValue', attr.$super.name)">{{ attr.$super }}</span>
|
||||
@click="emit('update:modelValue', attr.$super.name)">{{ attr.$super }}</span>
|
||||
</li>
|
||||
<li v-if="attr.equality">Equality: {{ attr.equality }}</li>
|
||||
<li v-if="attr.ordering">Ordering: {{ attr.ordering }}</li>
|
||||
@ -16,35 +16,12 @@
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
import Card from '../ui/Card.vue';
|
||||
|
||||
export default {
|
||||
name: 'AttributeCard',
|
||||
|
||||
components: {
|
||||
Card,
|
||||
},
|
||||
|
||||
props: {
|
||||
modelValue: String,
|
||||
},
|
||||
|
||||
inject: [ 'app' ],
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
hiddenFields: [ // not shown in schema panel
|
||||
'desc', 'name', 'names',
|
||||
'no_user_mod', 'obsolete', 'oid',
|
||||
'usage', 'syntax', 'sup' ]
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
attr: function() {
|
||||
return this.modelValue ? this.app.schema.attr(this.modelValue) : undefined;
|
||||
},
|
||||
},
|
||||
}
|
||||
const props = defineProps({ modelValue: String }),
|
||||
app = inject('app'),
|
||||
attr = computed(() => app.schema.attr(props.modelValue)),
|
||||
emit = defineEmits(['show-attr', 'update:modelValue']);
|
||||
</script>
|
||||
|
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<card v-if="modelValue" :title="oc.name" @close="$emit('update:modelValue')">
|
||||
<card v-if="modelValue" :title="oc.name" @close="emit('update:modelValue')">
|
||||
<div class="header">{{ oc.desc }}</div>
|
||||
|
||||
<div v-if="oc.sup.length" class="mt-2"><i>Superclasses:</i>
|
||||
<ul class="list-disc">
|
||||
<li v-for="name in oc.sup" :key="name">
|
||||
<span class="cursor-pointer" @click="$emit('update:modelValue', name)">{{ name }}</span>
|
||||
<span class="cursor-pointer" @click="emit('update:modelValue', name)">{{ name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -13,7 +13,7 @@
|
||||
<div v-if="oc.$collect('must').length" class="mt-2"><i>Required attributes:</i>
|
||||
<ul class="list-disc">
|
||||
<li v-for="name in oc.$collect('must')" :key="name">
|
||||
<span class="cursor-pointer" @click="app.attr = name;">{{ name }}</span>
|
||||
<span class="cursor-pointer" @click="emit('show-attr', name)">{{ name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -21,7 +21,7 @@
|
||||
<div v-if="oc.$collect('may').length" class="mt-2"><i>Optional attributes:</i>
|
||||
<ul class="list-disc">
|
||||
<li v-for="name in oc.$collect('may')" :key="name">
|
||||
<span class="cursor-pointer" @click="app.attr = name;">{{ name }}</span>
|
||||
<span class="cursor-pointer" @click="emit('show-attr', name)">{{ name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -29,26 +29,12 @@
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
import Card from '../ui/Card.vue';
|
||||
|
||||
export default {
|
||||
name: 'ObjectClassCard',
|
||||
|
||||
components: {
|
||||
Card,
|
||||
},
|
||||
|
||||
props: {
|
||||
modelValue: String,
|
||||
},
|
||||
|
||||
inject: [ 'app' ],
|
||||
|
||||
computed: {
|
||||
oc: function() {
|
||||
return this.modelValue ? this.app.schema.oc(this.modelValue) : undefined;
|
||||
},
|
||||
},
|
||||
}
|
||||
const props = defineProps({ modelValue: String }),
|
||||
app = inject('app'),
|
||||
oc = computed(() => app.schema.oc(props.modelValue)),
|
||||
emit = defineEmits(['show-attr', 'show-oc', 'update:modelValue']);
|
||||
</script>
|
||||
|
@ -152,20 +152,16 @@ export function LdapSchema(json) {
|
||||
}
|
||||
|
||||
// LdapSchema constructor
|
||||
const syntaxes = new PropertyMap(json.syntaxes, Syntax, 'oid'),
|
||||
attributes = new FlatPropertyMap(json.attributes, Attribute, 'names'),
|
||||
objectClasses = new FlatPropertyMap(json.objectClasses, ObjectClass, 'names');
|
||||
|
||||
Attribute.prototype.$syntaxes = syntaxes;
|
||||
RDN.prototype.$attributes = attributes;
|
||||
ObjectClass.prototype.$attributes = attributes;
|
||||
ObjectClass.values = objectClasses;
|
||||
|
||||
this.attr = (name) => attributes.$get(name);
|
||||
this.oc = (name) => objectClasses.$get(name);
|
||||
|
||||
this.DN = DN;
|
||||
this.RDN = RDN;
|
||||
this.Attribute = Attribute;
|
||||
this.ObjectClass = ObjectClass;
|
||||
|
||||
Attribute.prototype.$syntaxes = new PropertyMap(json.syntaxes, Syntax, 'oid');
|
||||
ObjectClass.prototype.$attributes = new FlatPropertyMap(json.attributes, Attribute, 'names'),
|
||||
RDN.prototype.$attributes = ObjectClass.prototype.$attributes;
|
||||
ObjectClass.values = new FlatPropertyMap(json.objectClasses, ObjectClass, 'names');
|
||||
|
||||
this.attr = (name) => ObjectClass.prototype.$attributes.$get(name);
|
||||
this.oc = (name) => ObjectClass.values.$get(name);
|
||||
}
|
||||
|
@ -3,7 +3,9 @@
|
||||
<slot name="header">
|
||||
<div class="py-2 border-b border-front/20">
|
||||
<strong class="pl-6">{{ title }}</strong>
|
||||
<span class="control text-l float-right mr-2 pl-2" @click="$emit('close')">⊗</span>
|
||||
<span class="control text-l float-right mr-2 pl-2" title="close"
|
||||
@click="emit('close')">⊗
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<div class="px-6 py-2">
|
||||
@ -12,12 +14,10 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Card',
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
});
|
||||
|
||||
props: {
|
||||
title: { type: String, required: true },
|
||||
},
|
||||
}
|
||||
const emit = defineEmits('close');
|
||||
</script>
|
@ -15,24 +15,10 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Popover from './Popover.vue';
|
||||
|
||||
export default {
|
||||
name: 'DropdownMenu',
|
||||
|
||||
components: {
|
||||
Popover,
|
||||
},
|
||||
|
||||
props: {
|
||||
title: String,
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
open: false,
|
||||
};
|
||||
},
|
||||
}
|
||||
const open = ref(false);
|
||||
defineProps({ title: String });
|
||||
</script>
|
||||
|
@ -4,32 +4,34 @@
|
||||
<div v-if="open" class="fixed w-full h-full top-0 left-0 z-20 bg-front/60 dark:bg-back/70" />
|
||||
</transition>
|
||||
|
||||
<transition name="bounce" @enter="$emit('show')" @after-enter="$emit('shown')"
|
||||
@leave="$emit('hide')" @after-leave="$emit('hidden')">
|
||||
<transition name="bounce" @enter="emit('show')" @after-enter="emit('shown')"
|
||||
@leave="emit('hide')" @after-leave="emit('hidden')">
|
||||
|
||||
<div ref="backdrop" v-if="open" @click.self="onDismiss"
|
||||
<div ref="backdrop" v-if="open" @click.self="onCancel" @keydown.esc="onCancel"
|
||||
class="fixed w-full h-full top-0 left-0 flex items-center justify-center z-30" >
|
||||
|
||||
<div class=" absolute max-h-full w-1/2 max-w-lg container text-front overflow-hidden rounded bg-back border border-front/40">
|
||||
<div class="absolute max-h-full w-1/2 max-w-lg container text-front overflow-hidden rounded bg-back border border-front/40">
|
||||
<div class="flex justify-between items-start">
|
||||
|
||||
<div class="max-h-full w-full divide-y divide-front/30">
|
||||
<div v-if="title" class="flex justify-between items-center px-4 py-1">
|
||||
<h3 class="text-xl font-bold leading-normal">
|
||||
<h3 class="ui-modal-header text-xl font-bold leading-normal">
|
||||
<slot name="header">{{ title }}</slot>
|
||||
</h3>
|
||||
|
||||
<div v-if="closable" class="control text-xl" @click="onCancel">⊗</div>
|
||||
<div class="control text-xl" @click="onCancel" title="close">⊗</div>
|
||||
</div>
|
||||
|
||||
<div class="ui-modal-body p-4 space-y-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-show="!hideFooter" class="flex justify-end w-full p-4 space-x-3">
|
||||
<div v-show="!hideFooter" class="ui-modal-footer flex justify-end w-full p-4 space-x-3">
|
||||
<slot name="footer">
|
||||
<button v-if="closable" @click="onCancel" type="button" :class="'bg-' + cancelVariant">{{ cancelTitle }}</button>
|
||||
<button @click="onOk" type="button" :class="'bg-' + okVariant">
|
||||
<button id="ui-modal-cancel" @click="onCancel" type="button" :class="cancelClasses">
|
||||
<slot name="modal-cancel">{{ cancelTitle }}</slot>
|
||||
</button>
|
||||
<button id="ui-modal-ok" @click.stop="onOk" type="button" :class="okClasses">
|
||||
<slot name="modal-ok">{{ okTitle }}</slot>
|
||||
</button>
|
||||
</slot>
|
||||
@ -42,42 +44,28 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
export default {
|
||||
name: 'Modal',
|
||||
|
||||
props: {
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
open: Boolean,
|
||||
open: { type: Boolean, required: true },
|
||||
okTitle: { type: String, default: 'OK' },
|
||||
okVariant: { type: String, default: 'primary' },
|
||||
okClasses: { type: String, default: 'bg-primary/80' },
|
||||
cancelTitle: { type: String, default: 'Cancel' },
|
||||
cancelVariant: { type: String, default: 'secondary' },
|
||||
closable: { type: Boolean, default: true },
|
||||
cancelClasses: { type: String, default: 'bg-secondary' },
|
||||
hideFooter: { type: Boolean, default: false },
|
||||
},
|
||||
returnTo: String,
|
||||
}),
|
||||
emit = defineEmits(['ok', 'cancel', 'show', 'shown', 'hide', 'hidden']);
|
||||
|
||||
methods: {
|
||||
onDismiss: function(e) {
|
||||
if (this.closable) this.onCancel(e);
|
||||
},
|
||||
function onOk() {
|
||||
if (props.open) emit('ok');
|
||||
}
|
||||
|
||||
onOk: function() {
|
||||
if (this.open) this.$emit('ok');
|
||||
},
|
||||
|
||||
onCancel: function() {
|
||||
if (this.open) this.$emit('cancel');
|
||||
},
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
if (this.closable) useEventListener(document, 'keydown', e => {
|
||||
if (e.key == 'Esc' || e.key == 'Escape') this.onDismiss();
|
||||
});
|
||||
},
|
||||
function onCancel() {
|
||||
if (props.open) {
|
||||
if (props.returnTo) document.getElementById(props.returnTo).focus();
|
||||
emit('cancel');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -87,7 +75,11 @@
|
||||
}
|
||||
|
||||
.ui-modal-body input, .ui-modal-body textarea, .ui-modal-body select {
|
||||
@apply w-full border border-front/20 rounded p-2 mt-1 outline-none focus:border-accent text-front bg-gray-200/80 dark:bg-gray-800/80;
|
||||
@apply w-full border border-front/20 rounded p-2 mt-1 outline-none focus:border-primary text-front bg-gray-200/80 dark:bg-gray-800/80;
|
||||
}
|
||||
|
||||
.ui-modal-footer button {
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.bounce-enter-active {
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<transition name="fade" @after-enter="$emit('opened')" @after-leave="$emit('closed')">
|
||||
<transition name="fade" @after-enter="emit('opened')" @after-leave="emit('closed')">
|
||||
<div v-if="open"
|
||||
class="ui-popover absolute z-10 border border-front/70 rounded min-w-max text-front bg-back list-none">
|
||||
<ul class="bg-front/5 dark:bg-front/10 py-2" @click="close">
|
||||
@ -9,35 +9,27 @@
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
export default {
|
||||
name: 'Popover',
|
||||
const props = defineProps({ open: Boolean }),
|
||||
emit = defineEmits(['opened', 'closed', 'update:open']);
|
||||
|
||||
props: {
|
||||
open: Boolean,
|
||||
},
|
||||
|
||||
methods: {
|
||||
close: function() {
|
||||
if (this.open) {
|
||||
this.$emit('update:open');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
useEventListener(document, 'keydown', e => {
|
||||
if (e.key == 'Esc' || e.key == 'Escape') this.close();
|
||||
});
|
||||
useEventListener(document, 'click', this.close);
|
||||
},
|
||||
function close() {
|
||||
if (props.open) emit('update:open');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
useEventListener(document, 'keydown', e => {
|
||||
if (e.key == 'Esc' || e.key == 'Escape') close();
|
||||
});
|
||||
useEventListener(document, 'click', close);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ui-popover [role=menuitem] {
|
||||
@apply cursor-pointer px-4 hover:bg-front/70 hover:text-back;
|
||||
@apply cursor-pointer px-4 hover:bg-primary/40;
|
||||
}
|
||||
</style>
|
@ -7,8 +7,7 @@
|
||||
--color-front: 32 32 32;
|
||||
--color-back: 255 255 255;
|
||||
|
||||
--color-accent: 23 162 184;
|
||||
--color-primary: 21 101 192;
|
||||
--color-primary: 23 162 184;
|
||||
--color-secondary: 108 117 125;
|
||||
--color-danger: 229 57 53;
|
||||
}
|
||||
|
@ -16,11 +16,10 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: withOpacityValue('--color-accent'),
|
||||
primary: withOpacityValue('--color-primary'),
|
||||
back: withOpacityValue('--color-back'),
|
||||
danger: withOpacityValue('--color-danger'),
|
||||
front: withOpacityValue('--color-front'),
|
||||
primary: withOpacityValue('--color-primary'),
|
||||
secondary: withOpacityValue('--color-secondary'),
|
||||
}
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user