Switch to composition API

This commit is contained in:
dnknth 2023-08-21 12:48:30 +02:00
parent f70c73e28a
commit 5652e62c68
30 changed files with 1062 additions and 1415 deletions

View File

@ -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

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "ldap-ui",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"scripts": {
"dev": "vite",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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);
}

View File

@ -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>

View File

@ -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>

View File

@ -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 {

View File

@ -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>

View File

@ -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;
}

View File

@ -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'),
}
},