Switch to Tailwind for CSS

Refactor modals

Update dependencies
This commit is contained in:
dnknth 2023-07-06 15:28:57 +02:00
parent 62818b6c61
commit d463a6e4ac
36 changed files with 1999 additions and 2031 deletions

4
.gitignore vendored
View File

@ -1,5 +1,6 @@
.mypy_cache
.venv*
.activate
__pycache__
.DS_Store
@ -17,7 +18,8 @@ pnpm-debug.log*
# Editor directories and files
.idea
.vscode
# https://github.com/tailwindlabs/tailwindcss/discussions/5258
# .vscode
*.suo
*.ntvs*
*.njsproj

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"css.customData": [".vscode/tailwind.json"]
}

55
.vscode/tailwind.json vendored Normal file
View File

@ -0,0 +1,55 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@tailwind",
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
}
]
},
{
"name": "@apply",
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that youd like to extract to a new component.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
}
]
},
{
"name": "@responsive",
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
}
]
},
{
"name": "@screen",
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
}
]
},
{
"name": "@variants",
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
}
]
}
]
}

View File

@ -104,4 +104,5 @@ Additionally, arbitrary attributes can be searched with an LDAP filter specifica
The Python backend uses [Quart](https://pgjones.gitlab.io/quart/index.html) which is an asynchronous [Flask](http://flask.pocoo.org/). Kudos for the authors of these elegant frameworks!
The UI uses [Vue.js](https://vuejs.org) with the excellent [Bootstrap Vue](https://bootstrap-vue.js.org) components. Thanks to the authors for making frontend work much more enjoyable.
The UI is built with [Vue.js](https://vuejs.org) and UI elements based on [Vue Tailwind](https://vuetailwind.com/).
Thanks to the authors for making frontend work much more enjoyable.

View File

@ -9,16 +9,15 @@
<meta name="googlebot" content="noindex, nofollow">
<meta name="theme-color" content="aliceblue" />
<link rel="icon" href="/favicon.ico">
<script type="module" src="/src/main.js"></script>
<title>Directory</title>
</head>
<body>
<body class="bg-back text-front">
<div id="app"></div>
<noscript>
<strong>We're sorry but JavaScript is required. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@ -1,5 +1,8 @@
{
"include": [
"./src/**/*"
]
],
"vueCompilerOptions": {
"target": 2.7
}
}

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,27 +1,39 @@
{
"name": "ldap-ui",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"scripts": {
"dev": "vite",
"lint": "eslint src",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"tw-config": "tailwind-config-viewer -o"
},
"dependencies": {
"bootstrap-vue": "^2.23.0",
"@vueuse/components": "^10.2.1",
"@vueuse/core": "^10.2.1",
"font-awesome": "^4.7.0",
"vue": "^2.7.0"
},
"devDependencies": {
"@vitejs/plugin-vue2": "^2.0.0",
"autoprefixer": "^10.4.14",
"eslint": "latest",
"eslint-plugin-vue": "^9.15.1",
"tailwind-config-viewer": "^1.7.2",
"tailwindcss": "^3.3.2",
"vite": "^4.0.0",
"vite-plugin-compression": "^0.5.1"
},
"eslintConfig": {
"root": true,
"extends": "eslint:recommended",
"env": {
"node": true
},
"extends": [
"eslint:recommended",
"plugin:vue/vue-essential"
],
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 2015

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,293 +1,204 @@
<template>
<div id="app">
<nav-bar v-model="treeOpen" :dn="activeDn" :base-dn="baseDn"
:schema="schema" :user="user" :showWarning="showWarning"
@show-modal="modal = $event;"
@select-dn="activeDn = $event;"
@display-oc="displayOc" />
<nav-bar v-model="treeOpen" :dn="activeDn" :base-dn="baseDn" :user="user"
:showWarning="showWarning" :schema="schema"
@select-dn="activeDn = $event" @display-oc="displayOc" />
<ldif-import-dialog v-model="modal" @ok="activeDn = $event;" />
<ldif-import-dialog @select-dn="activeDn = $event" />
<div class="flex container">
<div class="space-y-4"><!-- left column -->
<tree-view v-model="activeDn" v-show="treeOpen" :schema="schema" @base-dn="baseDn = $event;" />
<object-class-card v-if="oc" :oc="oc" @display-oc="displayOc" @display-attr="displayAttr" />
<attribute-card v-if="attr" :attr="attr" @display-attr="displayAttr" />
</div>
<div class="flex-auto mt-4"><!-- main editing area -->
<b-container fluid>
<b-row>
<b-col cols="*" id="left">
<tree-view v-model="activeDn" :shown="treeOpen" :schema="schema"
@base-dn="baseDn = $event" />
<object-class-card :oc="oc" @display-oc="displayOc" @display-attr="displayAttr" />
<attribute-card v-if="attr" :attr="attr" @display-attr="displayAttr" />
</b-col>
<b-col id="main">
<b-alert dismissible fade :variant="error.type"
:show="error && error.counter" @dismissed="error.counter=0">
<transition name="fade"><!-- Notifications -->
<div v-if="error"
class="rounded mx-4 mb-4 p-4 border border-front/70 text-back/70" :class="error.type">
{{ error.msg }}
</b-alert>
<editor v-model="activeDn" :showInfo="showInfo"
:schema="schema" :user="user" :base-dn="baseDn"
@display-attr="displayAttr" @display-oc="displayOc" />
<span class="float-right pr-2 hover:text-back" @click="error = undefined"></span>
</div>
</transition>
<editor v-model="activeDn" :showInfo="showInfo"
:schema="schema" :user="user" :base-dn="baseDn"
@display-attr="displayAttr" @display-oc="displayOc" />
</div>
</div>
</b-col>
</b-row>
</b-container>
<div v-if="false"><!-- Not rendered, prevents color pruning -->
<span class="text-accent bg-accent"></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>
import AttributeCard from './components/schema/AttributeCard.vue';
import Editor from './components/editor/Editor.vue';
import { LdapSchema } from './components/schema/schema.js';
import LdifImportDialog from './components/LdifImportDialog.vue';
import NavBar from './components/NavBar.vue';
import ObjectClassCard from './components/schema/ObjectClassCard.vue';
import { request } from './request.js';
import TreeView from './components/TreeView.vue';
import AttributeCard from './components/schema/AttributeCard.vue';
import Editor from './components/editor/Editor.vue';
import { LdapSchema } from './components/schema/schema.js';
import LdifImportDialog from './components/LdifImportDialog.vue';
import NavBar from './components/NavBar.vue';
import ObjectClassCard from './components/schema/ObjectClassCard.vue';
import { request } from './request.js';
import TreeView from './components/TreeView.vue';
export default {
name: 'App',
export default {
name: 'App',
components: {
AttributeCard,
Editor,
LdifImportDialog,
NavBar,
ObjectClassCard,
TreeView,
},
data: function() {
return {
// authentication
user: null, // logged in user
baseDn: undefined,
treeOpen: true, // Is the tree visible?
activeDn: undefined, // currently active DN in the editor
// alerts
error: {}, // status alert
// schema
schema: new LdapSchema({}),
// Flash cards
oc: null, // objectClass info in side panel
attr: null, // attribute info in side panel
}
},
provide: function () {
return { xhr: this.xhr, }
},
mounted: async function() { // Runs on page load
// Get the DN of the current user
this.user = await this.xhr({ url: 'api/whoami'});
// Load the schema
this.schema = new LdapSchema(await this.xhr({ url: 'api/schema' }));
document.getElementById('search').focus();
},
methods: {
xhr: function(options) {
return request(options)
.then(xhr => JSON.parse(xhr.response))
.catch(xhr => this.showException(xhr.response || "Unknown error"));
},
displayOc: function(name) {
this.attr = undefined;
this.oc = name ? this.schema.oc(name) : undefined;
},
displayAttr: function(name) {
this.attr = name ? this.schema.attr(name) : undefined;
this.oc = undefined;
},
// Display an info popup
showInfo: function(msg) {
this.error = { counter: 5, type: 'success', msg: '' + msg }
},
// Flash a warning popup
showWarning: function(msg) {
this.error = { counter: 10, type: 'warning', msg: '⚠️ ' + msg }
},
// Report an error
showError: function(msg) {
this.error = { counter: 60, type: 'danger', msg: '⛔ ' + msg }
components: {
AttributeCard,
Editor,
LdifImportDialog,
NavBar,
ObjectClassCard,
TreeView,
},
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]);
data: function() {
return {
// 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
// alerts
error: undefined, // status alert
// schema
schema: new LdapSchema({}),
// Flash cards
oc: undefined, // objectClass info in side panel
attr: undefined, // attribute info in side panel
}
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);
},
},
}
provide: function () {
return {
xhr: this.xhr,
}
},
mounted: async function() { // Runs on page load
// Get the DN of the current user
this.user = await this.xhr({ url: 'api/whoami'});
// Load the schema
this.schema = new LdapSchema(await this.xhr({ url: 'api/schema' }));
},
methods: {
xhr: function(options) {
return request(options)
.then(xhr => JSON.parse(xhr.response))
.catch(xhr => this.showException(xhr.response || "Unknown error"));
},
displayOc: function(name) {
this.attr = undefined;
this.oc = name ? this.schema.oc(name) : undefined;
},
displayAttr: function(name) {
this.oc = undefined;
this.attr = name ? this.schema.attr(name) : undefined;
},
// 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);
},
},
}
</script>
<style>
:root {
--body-fg: #222;
--body-bg: white;
--muted-fg: #333;
--muted-bg: #EEE;
--accent: var(--cyan);
--active: black;
--border: rgb(0,0,0,.125);
--input-bg: white;
--modal-border: rgba(0,0,0,.2);
--modal-divider: #dee2e6;
--tree-icon: DarkGray;
--tree-bg: var(--body-bg);
--tree-shadow: rgba(0,0,0,0.5);
}
@media (prefers-color-scheme: dark) {
:root {
--body-fg: #EEE;
--body-bg: #111;
--muted-fg: #CCC;
--muted-bg: #222;
--active: white;
--border: #333;
--input-bg: #444;
--modal-border: #666;
--modal-divider: #444;
--tree-icon: LightGray;
--tree-bg: var(--muted-bg);
--tree-shadow: rgba(128,128,128,0.5);
}
select.custom-select {
background: var(--input-bg) url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23CCC' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right .75rem center/8px 10px no-repeat;
}
}
body {
color: var(--body-fg);
background-color: var(--body-bg);
}
div.card {
margin-bottom: 1em;
background-color: var(--muted-bg);
color: var(--body-fg);
border: 1px solid var(--border);
}
.card div.card-header {
color: var(--muted-fg);
}
div.card-body {
background-color: var(--body-bg);
}
a, a:hover {
text-decoration: none;
color: var(--active);
}
.u {
text-decoration: underline;
}
.right {
float: right;
margin-left: 0.3em;
}
.red {
color: red !important;
}
.green {
color: green !important;
}
.clickable {
cursor: pointer;
}
.close {
color: var(--body-fg);
text-shadow: 0 1px 0 var(--body-bg);
}
.control {
opacity: 0.4;
cursor: pointer;
@apply opacity-70 hover:opacity-90 cursor-pointer select-none leading-none pt-1 pr-1;
}
.control:hover {
opacity: 0.75;
.form-control {
@apply text-front bg-gray-200/80 dark:bg-gray-800/80;
}
.close-box {
font-size: 150%;
position: absolute;
top: 0.2em;
right: 16px;
.modal input, .modal textarea, .modal select {
@apply form-control w-full border border-front/20 rounded p-2 mt-1 outline-none focus:border-accent text-front;
}
span.header, p.strong {
font-weight: bold;
.modal label {
@apply block text-front/70;
}
#main {
margin-top: 1em;
}
.hidden {
display: none;
}
input.form-control, textarea.form-control, select.custom-select {
color: var(--body-fg) !important;
background-color: var(--input-bg) !important;
}
.modal-header, .modal-footer {
border-color: var(--modal-divider);
}
.modal-content {
border-color: var(--modal-border);
background-color: var(--body-bg);
}
.dropdown-menu {
border: 1px solid var(--modal-border);
background-color: var(--muted-bg);
}
.dropdown-item {
color: var(--body-fg);
button, .btn, [type="button"] {
@apply border-none px-3 py-2 rounded text-back dark:text-front;
}
.glyph {
font-family: sans-serif, FontAwesome;
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;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>

23
src/components/Card.vue Normal file
View File

@ -0,0 +1,23 @@
<template>
<div class="max-w-sm rounded overflow-hidden shadow-lg border border-front/20">
<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>
</div>
</slot>
<div class="px-6 py-2">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'Card',
props: {
title: String,
},
}
</script>

View File

@ -0,0 +1,38 @@
<template>
<div class="relative inline-block text-left mx-1">
<span ref="opener" class="inline-flex w-full py-2 select-none cursor-pointer"
:aria-expanded="open" aria-haspopup="true" @click.prevent="open = !open;">
<slot name="button-content">
{{ title }}
</slot>
<svg class="-mr-1 h-5 w-5 pt-1" viewBox="0 0 20 20" fill="currentColor" :aria-hidden="!open">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
</svg>
</span>
<popover v-if="open" :open="open" @close="open = false;">
<slot></slot>
</popover>
</div>
</template>
<script>
import Popover from './Popover.vue';
export default {
name: 'DropdownMenu',
components: {
Popover,
},
props: {
title: String,
},
data: function() {
return {
open: false,
}
},
}
</script>

View File

@ -1,65 +1,76 @@
<template>
<b-modal id="ldif-import" title="Import" ok-title="Import" @show="reset" @ok="done">
<textarea v-model="ldifData" class="mb-3 form-control"
id="ldif-data" placeholder="Paste or upload LDIF">
<modal title="Import" :open="modal == 'ldif-import'" ok-title="Import"
@show="init" @ok="onOk" @cancel="$emit('close')">
<textarea v-model="ldifData" id="ldif-data" placeholder="Paste or upload LDIF">
</textarea>
<input type="file" value="Upload…" @change="upload" accept=".ldif" />
</b-modal>
</modal>
</template>
<script>
import Modal from './Modal.vue';
export default {
export default {
name: 'LdifImportDialog',
name: 'LdifImportDialog',
inject: [ 'xhr' ],
data: function() {
return {
ldifData: '',
ldifFile: null,
}
},
methods: {
reset: function() {
this.ldifData = '';
this.ldifFile = null;
components: {
Modal,
},
// 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;
inject: [ 'xhr' ],
props: {
modal: String,
},
model: {
prop: 'modal',
event: 'close',
},
data: function() {
return {
ldifData: '',
ldifFile: null,
}
reader.readAsText(file);
},
// Import LDIF
done: async function(evt) {
if (!this.ldifData) {
evt.preventDefault();
return;
}
const xhr = await this.xhr({
url: 'api/ldif',
method: 'POST',
data: this.ldifData,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
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;
}
if (xhr) this.$emit('select-dn', '-');
this.$emit('close');
const xhr = await this.xhr({
url: 'api/ldif',
method: 'POST',
data: this.ldifData,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
if (xhr) this.$emit('ok', '-');
},
},
},
}
}
</script>
<style scoped>
</style>

122
src/components/Modal.vue Normal file
View File

@ -0,0 +1,122 @@
<template>
<div>
<transition name="fade">
<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')">
<div ref="backdrop" v-if="open" @click.self="onDismiss"
class="fixed w-full h-full top-0 left-0 flex items-center justify-center z-30" >
<div class="modal 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">
<slot name="header">{{ title }}</slot>
</h3>
<div v-if="closable" class="control text-xl" @click="onCancel"></div>
</div>
<div class="p-4 space-y-4">
<slot />
</div>
<div v-show="!hideFooter" class="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">
<slot name="modal-ok">{{ okTitle }}</slot>
</button>
</slot>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
import { useEventListener } from '@vueuse/core';
export default {
name: 'Modal',
props: {
title: String,
open: Boolean,
okTitle: {
type: String,
default: "OK",
},
okVariant: {
type: String,
default: "primary",
},
cancelTitle: {
type: String,
default: "Cancel",
},
cancelVariant: {
type: String,
default: "secondary",
},
closable: {
type: Boolean,
default: true,
},
hideFooter: {
type: Boolean,
default: false,
},
},
methods: {
onDismiss: function(e) {
if (this.closable) this.onCancel(e);
},
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();
});
},
}
</script>
<style scoped>
.bounce-enter-active {
animation: bounce-in 0.5s;
}
.bounce-leave-active {
animation: bounce-in 0.5s reverse;
}
@keyframes bounce-in {
0% { transform: scale(0); }
50% { transform: scale(1.15); }
100% { transform: scale(1); }
}
</style>

View File

@ -1,114 +1,77 @@
<template>
<b-navbar toggleable="md" type="dark" variant="info">
<b-navbar-toggle target="nav_collapse" />
<b-navbar-brand>
<i class="clickable fa" :class="treeOpen ? 'fa-list-alt' : 'fa-list-ul'"
@click="$emit('toggle-tree', !treeOpen)"></i>
<node-label oc="person" :dn="user" cssClass="clickable"
@select-dn="$emit('select-dn', $event)" />
</b-navbar-brand>
<b-collapse is-nav id="nav_collapse">
<b-navbar-nav class="ml-auto"><!-- Right aligned nav items -->
<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">
<div class="flex items-center">
<i class="cursor-pointer glyph fa-bars fa-lg pt-1 mr-4 md:hidden" @click="collapsed = !collapsed"></i>
<b-nav-item v-b-modal.ldif-import>Import…</b-nav-item>
<b-nav-item-dropdown text="Schema" right>
<b-dropdown-item v-for="obj in schema.objectClasses._objects"
:key="obj.name" @click="$emit('display-oc', obj.name)">
{{ obj.name }}
</b-dropdown-item>
</b-nav-item-dropdown>
<i class="cursor-pointer fa fa-lg mr-2" :class="treeOpen ? 'fa-list-alt' : 'fa-list-ul'"
@click="$emit('toggle-tree', !treeOpen)"></i>
<node-label oc="person" :dn="user" @select-dn="$emit('select-dn', $event)" class="text-lg" />
</div>
<b-nav-form @submit.prevent="query = search">
<input size="sm" class="glyph mr-sm-2 form-control" id="search" v-model="search"
:placeholder="'\uf002'" name="q" onClick="this.select();" @keyup.esc="query = ''" />
<search-results for="search" @select-dn="query = ''; $emit('select-dn', $event)"
:shorten="baseDn" :query="query" :warning="showWarning" placement="bottomleft" />
</b-nav-form>
<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>
<dropdown-menu title="Schema">
<li role="menuitem" v-for="obj in schema.objectClasses._objects"
:key="obj.name" @click="$emit('display-oc', obj.name)">
{{ obj.name }}
</li>
</dropdown-menu>
</b-navbar-nav>
</b-collapse>
</b-navbar>
<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="baseDn" :query="query" :warning="showWarning" />
</form>
</div>
</nav>
</template>
<script>
import DropdownMenu from './DropdownMenu.vue';
import NodeLabel from './NodeLabel.vue';
import SearchResults from './SearchResults.vue';
import NodeLabel from './NodeLabel.vue';
import SearchResults from './SearchResults.vue';
export default {
name: 'NavBar',
export default {
name: 'NavBar',
components: {
NodeLabel,
SearchResults,
},
props: {
dn: String,
baseDn: String,
user: String,
showWarning: {
type: Function,
required: true,
components: {
DropdownMenu,
NodeLabel,
SearchResults,
},
treeOpen: {
type: Boolean,
required: true,
props: {
dn: String,
baseDn: String,
user: String,
showWarning: Function,
treeOpen: Boolean,
schema: Object,
},
schema: {
type: Object,
required: true,
model: {
prop: 'treeOpen',
event: 'toggle-tree',
},
},
model: {
prop: 'treeOpen',
event: 'toggle-tree',
},
data: function() {
return {
query: '',
collapsed: false,
}
},
data: function() {
return {
query: '',
search: '',
}
},
watch: {
dn: function() { this.query = ''; },
search: function(q) { if (!q) this.query = ''; },
},
}
methods: {
search: function() {
const q = this.$refs.q.value;
this.query = '';
this.$nextTick(() => { this.query = q; });
},
},
}
</script>
<style>
@media (prefers-color-scheme: dark) {
.navbar-dark .navbar-toggler {
border-color: var(--muted-fg);
}
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") !important;
}
}
.navbar-brand i.fa-list-alt, .navbar-brand i.fa-list-ul {
margin-right: 1em;
}
.navbar-dark .navbar-nav .nav-link {
color: white;
}
a.nav-link {
padding-right: 0;
}
</style>

View File

@ -1,69 +1,52 @@
<template>
<span @click="$emit('select-dn', dn)" class="node-label" :class="cssClass" :title="dn">
<i class="fa" :class="icon" v-if="oc"></i>
<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 {
export default {
name: 'NodeLabel',
name: 'NodeLabel',
props: {
dn: String,
cssClass: {
type: String,
default: '',
props: {
dn: String,
oc: String,
},
oc: {
type: String,
required: true,
},
},
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',
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',
}
}
}
},
computed: {
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=/, '');
computed: {
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=/, '');
},
},
},
}
}
</script>
<style scoped>
i.fa {
margin-right: 0.3em;
}
.node-label:hover {
color: var(--active);
}
</style>

View File

@ -0,0 +1,47 @@
<template>
<div :class="{ hidden : !open }" v-on-click-outside="close"
class="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">
<slot></slot>
</ul>
</div>
</template>
<script>
import { useEventListener } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
export default {
name: 'Popover',
props: {
open: Boolean,
},
directives: {
'on-click-outside': vOnClickOutside,
},
methods: {
close: function(evt) {
if (this.open) {
evt.stopPropagation();
this.$emit('close');
}
},
},
mounted: function() {
useEventListener(document, "keydown", e => {
if (e.key == "Esc" || e.key == "Escape") this.close(e);
});
},
}
</script>
<style scoped>
[role=menuitem] {
@apply cursor-pointer px-4 hover:bg-front/70 hover:text-back;
}
</style>

View File

@ -1,136 +1,103 @@
<template>
<b-popover :target="elementId" :show="show" v-if="show" :placement="placement">
<div class="search-results">
<div v-for="item in results" :key="item.dn" @click="done(item.dn)"
:title="label == 'dn' ? '' : item.dn">
{{ display(item) }}
</div>
</div>
</b-popover>
<popover :target="elementId" v-if="show" :open="show" @close="clear">
<li v-for="item in results" :key="item.dn" @click="done(item.dn)"
:title="label == 'dn' ? '' : trim(item.dn)" role="menuitem">
{{ item[label] }}
</li>
</popover>
</template>
<script>
import Popover from './Popover.vue';
export default {
export default {
name: 'SearchResults',
name: 'SearchResults',
props: {
query: String,
for: {
type: String,
required: true,
components: {
Popover,
},
placement: {
type: String,
default: 'bottom',
props: {
query: String,
for: String,
label: {
type: String,
default: 'name',
validator: value => ['name', 'dn' ].includes(value)
},
shorten: String,
warning: Function,
},
label: {
type: String,
default: 'name',
validator: value => ['name', 'dn' ].includes(value)
data: function() {
return {
results: [],
popup: null,
}
},
shorten: String,
warning: Function,
},
inject: [ 'xhr' ],
data: function() {
return {
results: [],
popup: null,
watch: {
query: async function(q) {
if (!q) return;
this.clear();
this.results = await this.xhr({ url: 'api/search/' + q });
if (!this.results) return; // XHR failed
if (this.results.length == 0) {
if (this.warning) this.warning('No search results');
return;
}
if (this.results.length == 1) {
this.done(this.results[0].dn);
return;
}
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.clear();
// Return focus to search input
this.$nextTick(function() {
const el = document.getElementById(this.for);
if (el) el.focus();
});
},
clear: function() {
this.results = [];
},
},
computed: {
elementId: function() { // alias "for" prop for templates
return this.for;
},
show: function() {
return this.query && this.results && this.results.length > 1;
},
}
},
inject: [ 'xhr' ],
watch: {
query: async function(q) {
this.results = [];
if (!q) return;
this.results = await this.xhr({ url: 'api/search/' + q });
if (!this.results) return; // XHR failed
if (this.results.length == 0) {
if (this.warning) this.warning('No search results');
return;
}
if (this.results.length == 1) {
this.done(this.results[0].dn);
return;
}
this.results.sort((a, b) =>
a[this.label].toLowerCase().localeCompare(
b[this.label].toLowerCase()));
},
},
methods: {
display: function(item) {
let label = item[this.label];
if (this.shorten && this.shorten != label) {
label = label.replace(this.shorten, '…');
}
return label;
},
// use an auto-completion choice
done: function(dn) {
this.$emit('select-dn', dn);
this.clear();
// Return focus to search input
this.$nextTick(function() {
const el = document.getElementById(this.for);
if (el) el.focus();
});
},
clear: function() {
this.results = [];
},
},
computed: {
elementId: function() { // alias "for" prop for templates
return this.for;
},
show: function() {
return this.results && this.results.length > 1;
},
}
}
</script>
<style>
.b-popover .popover-body {
background-color: var(--muted-bg);
color: var(--muted-fg);
border: 1px solid var(--accent);
border-radius: 4px;
}
.search-results {
padding-left: 0px;
padding-inline-start: 0px !important;
}
.search-results div {
list-style-type: none;
cursor: pointer;
}
.search-results div:hover {
color: var(--body-fg);
}
</style>

View File

@ -1,19 +1,18 @@
<template>
<div id="tree-view">
<ul id="tree" v-if="shown && tree" class="list-unstyled">
<div class="rounded-md bg-front/[.07] p-4 shadow-md shadow-front/20">
<ul v-if="tree" class="list-unstyled">
<li v-for="item in tree.visible()" :key="item.dn"
:id="item.dn" :class="item.structuralObjectClass">
<span v-for="i in (item.level - tree.level)" class="indent" :key="i"></span>
<span v-if="item.hasSubordinates" class="clickable opener"
@click="toggle(item)"><i :class="'fa fa-chevron-circle-'
<span v-for="i in (item.level - tree.level)" class="ml-6" :key="i"></span>
<span v-if="item.hasSubordinates" class="control"
@click="toggle(item)"><i :class="'control p-0 fa fa-chevron-circle-'
+ (item.open ? 'down' : 'right')"></i></span>
<span v-else class="indent"></span>
<span v-else class="mr-4"></span>
<node-label :dn="item.dn" :oc="item.structuralObjectClass"
cssClass="clickable tree-link"
class="tree-link whitespace-nowrap text-front/80"
@select-dn="clicked" :class="{ active : active == item.dn }">
<span v-if="!item.level">
{{ item.dn }}
</span>
<span v-if="!item.level">{{ item.dn }}</span>
</node-label>
</li>
</ul>
@ -21,187 +20,141 @@
</template>
<script>
import NodeLabel from './NodeLabel.vue';
import NodeLabel from './NodeLabel.vue';
function Node(json) {
Object.assign(this, json);
this.level = this.dn.split(',').length;
if (this.hasSubordinates) {
this.subordinates = [];
this.open = false;
}
}
Node.prototype = {
find: function(dn) {
// Primitive recursive search for a DN.
// Compares DNs a strings, without any regard for
// distinguishedNameMatch rules.
// See: https://ldapwiki.com/wiki/DistinguishedNameMatch
if (this.dn == dn) return this;
const suffix = ',' + this.dn;
if (!dn.endsWith(suffix) || !this.hasSubordinates) return undefined;
return this.subordinates
.map(node => node.find(dn))
.filter(node => node)[0];
},
get loaded() {
return !this.hasSubordinates || this.subordinates.length > 0;
},
visible: function() {
if (!this.hasSubordinates || !this.open) return [this];
return [this].concat(
this.subordinates.flatMap(
node => node.visible()));
},
};
export default {
name: 'TreeView',
components: {
NodeLabel,
},
model: {
prop: 'active',
event: 'select-dn'
},
props: {
active: String,
shown: {
type: Boolean,
required: true,
},
schema: {
type: Object,
required: true,
},
},
inject: [ 'xhr' ],
data: function() {
return {
tree: undefined,
function Node(json) {
Object.assign(this, json);
this.level = this.dn.split(',').length;
if (this.hasSubordinates) {
this.subordinates = [];
this.open = false;
}
},
}
created: async function() {
await this.reload('base');
this.$emit('base-dn', this.tree.dn);
},
Node.prototype = {
find: function(dn) {
// Primitive recursive search for a DN.
// Compares DNs a strings, without any regard for
// distinguishedNameMatch rules.
// See: https://ldapwiki.com/wiki/DistinguishedNameMatch
watch: {
if (this.dn == dn) return this;
const suffix = ',' + this.dn;
if (!dn.endsWith(suffix) || !this.hasSubordinates) return undefined;
return this.subordinates
.map(node => node.find(dn))
.filter(node => node)[0];
},
active: async function(selected) {
get loaded() {
return !this.hasSubordinates || this.subordinates.length > 0;
},
// Special case: Full tree reload
if (selected == '-' || selected == 'base') {
await this.reload('base');
return;
}
visible: function() {
if (!this.hasSubordinates || !this.open) return [this];
return [this].concat(
this.subordinates.flatMap(
node => node.visible()));
},
};
// Reveal the selected DN in the tree
// by opening all parent nodes
const dn = new this.schema.DN(selected || this.tree.dn),
parents = dn.parents(this.tree.dn);
export default {
name: 'TreeView',
parents.reverse();
for (let i=0; i < parents.length; ++i) {
const p = parents[i].value, node = this.tree.find(p);
if (!node.loaded) await this.reload(p);
this.$set(node, 'open', true);
}
components: {
NodeLabel,
},
// Special case: Item was added, renamed or deleted
if (!this.tree.find(dn.value)) {
await this.reload(dn.parent.value);
this.$set(this.tree.find(dn.parent.value), 'open', true);
model: {
prop: 'active',
event: 'select-dn'
},
props: {
active: String,
schema: Object,
},
inject: [ 'xhr' ],
data: function() {
return {
tree: undefined,
}
},
},
methods: {
clicked: async function(dn) {
const item = this.tree.find(dn);
if (item.hasSubordinates && !item.open) await this.toggle(item);
this.$emit('select-dn', dn);
created: async function() {
await this.reload('base');
this.$emit('base-dn', this.tree.dn);
},
// Reload the subtree at entry with given DN
reload: async function(dn) {
const response = await this.xhr({ url: 'api/tree/' + dn }) || [];
response.sort((a, b) => a.dn.toLowerCase().localeCompare(b.dn.toLowerCase()));
watch: {
if (dn == 'base') {
this.tree = new Node(response[0]);
await this.toggle(this.tree);
return;
}
active: async function(selected) {
// Special case: Full tree reload
if (selected == '-' || selected == 'base') {
await this.reload('base');
return;
}
// Reveal the selected DN in the tree
// by opening all parent nodes
const dn = new this.schema.DN(selected || this.tree.dn),
parents = dn.parents(this.tree.dn);
parents.reverse();
for (let i=0; i < parents.length; ++i) {
const p = parents[i].value, node = this.tree.find(p);
if (!node.loaded) await this.reload(p);
this.$set(node, 'open', true);
}
// Special case: Item was added, renamed or deleted
if (!this.tree.find(dn.value)) {
await this.reload(dn.parent.value);
this.$set(this.tree.find(dn.parent.value), 'open', true);
}
},
const item = this.tree.find(dn);
this.$set(item, 'subordinates', response.map(node => new Node(node)));
return response;
},
methods: {
// Hide / show tree elements
toggle: async function(item) {
if (!item.open && !item.loaded) await this.reload(item.dn);
this.$set(item, 'open', !item.open);
clicked: async function(dn) {
const item = this.tree.find(dn);
if (item.hasSubordinates && !item.open) await this.toggle(item);
this.$emit('select-dn', dn);
},
// Reload the subtree at entry with given DN
reload: async function(dn) {
const response = await this.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;
}
const item = this.tree.find(dn);
this.$set(item, 'subordinates', response.map(node => new Node(node)));
return response;
},
// Hide / show tree elements
toggle: async function(item) {
if (!item.open && !item.loaded) await this.reload(item.dn);
this.$set(item, 'open', !item.open);
},
},
},
}
}
</script>
<style scoped>
#tree-view {
box-shadow: 4px 4px 5px 0 var(--tree-shadow);
background-color: var(--tree-bg);
.active {
@apply text-front font-bold;
}
ul#tree {
margin: 0 1em 1em 1em;
padding: 1em 0;
}
ul#tree li {
margin-top: 0.1em;
white-space: nowrap;
}
.tree-link i.fa {
color: var(--tree-icon);
}
span.active {
font-weight: bold;
}
span.indent {
margin-left: 1.2em;
}
span.opener {
opacity: 0.4;
margin-right: 0.3em;
}
span.opener:hover {
opacity: 0.7;
}
</style>

View File

@ -1,79 +1,67 @@
<template>
<b-modal id="add-attribute" title="Add attribute"
@show="reset" @shown="init" @ok="done" @hidden="focus">
<modal title="Add attribute" :open="modal == 'add-attribute'"
@show="attr = null;" @shown="$refs.attr.focus()"
@ok="onOk" @cancel="$emit('close')">
<b-form-select v-model="attr" id="new-attr" :options="available" class="mb-3"
@keydown.native.enter.prevent="done" />
</b-modal>
<select v-model="attr" ref="attr" @keyup.enter="onOk">
<option v-for="attr in available">{{ attr }}</option>
</select>
</modal>
</template>
<script>
import Modal from '../Modal.vue';
export default {
export default {
name: 'AddAttributeDialog',
name: 'AddAttributeDialog',
props: {
entry: {
type: Object,
required: true,
},
attributes: {
type: Array,
required: true,
},
},
data: function() {
return {
attr: null,
}
},
methods: {
reset: function() {
this.attr = null;
components: {
Modal,
},
init: function() {
document.getElementById('new-attr').focus();
props: {
entry: Object,
attributes: Array,
modal: String,
},
// Add the selected attribute
done: function(evt) {
if (!this.attr) {
evt.preventDefault();
return;
model: {
prop: 'modal',
event: 'close',
},
data: function() {
return {
attr: null,
}
this.$bvModal.hide('add-attribute');
if (this.attr == 'jpegPhoto' || this.attr == 'thumbnailPhoto') {
this.$bvModal.show('upload-' + this.attr);
return;
}
if (this.attr == 'userPassword') {
this.$bvModal.show('change-password');
return;
}
const entry = Object.assign({}, this.entry);
this.$set(entry.attrs, this.attr, ['']);
},
focus: function() {
this.$emit('update-form', this.attr + '-0');
},
},
computed: {
methods: {
// Add the selected attribute
onOk: function() {
if (!this.attr) return;
// Choice list for new attribute selection popup
available: function() {
const attrs = Object.keys(this.entry.attrs);
return this.attributes.filter(attr => !attrs.includes(attr));
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('close');
this.$emit('ok', this.attr);
},
},
},
}
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));
},
},
}
</script>

View File

@ -1,52 +1,54 @@
<template>
<b-modal id="add-oc" title="Add objectClass"
@show="reset" @shown="init" @ok="done" @hidden="$emit('update-form')">
<modal title="Add objectClass" :open="modal == 'add-object-class'"
@show="oc = null;" @shown="$refs.oc.focus()"
@ok="onOk" @cancel="$emit('close')">
<b-form-select v-model="oc" id="oc-select" class="mb-3" :options="available"
@keydown.native.enter.prevent="done" />
</b-modal>
<select v-model="oc" ref="oc" @keyup.enter="onOk">
<option v-for="cls in available">{{ cls }}</option>
</select>
</modal>
</template>
<script>
import Modal from '../Modal.vue';
export default {
export default {
name: 'AddObjectClassDialog',
name: 'AddObjectClassDialog',
props: {
entry: {
type: Object,
required: true,
},
},
data: function() {
return {
oc: null,
}
},
methods: {
reset: function() {
this.oc = null;
components: {
Modal,
},
init: function() {
document.getElementById('oc-select').focus();
props: {
entry: Object,
modal: String,
},
done: function() {
this.entry.attrs.objectClass.push(this.oc);
this.$bvModal.hide('add-oc');
model: {
prop: 'modal',
event: 'close',
},
},
computed: {
available: function() {
const classes = this.entry.attrs.objectClass;
return this.entry.meta.aux.filter(cls => !classes.includes(cls));
data: function() {
return {
oc: null,
}
},
},
}
methods: {
onOk: function() {
if (this.oc) {
this.$emit('close');
this.$emit('ok', this.oc);
}
},
},
computed: {
available: function() {
const classes = this.entry.attrs.objectClass;
return this.entry.meta.aux.filter(cls => !classes.includes(cls));
},
},
}
</script>

View File

@ -1,54 +1,57 @@
<template>
<b-modal :id="id" title="Upload photo" @shown="init" hide-footer>
<input v-if="attr === 'jpegPhoto'" type="file" name="photo" id="add-photo" accept="image/jpeg" @change="done" />
<input v-if="attr === 'thumbnailPhoto'" type="file" name="photo" id="add-photo" accept="image/*" @change="done" />
</b-modal>
<modal title="Upload photo" hide-footer :open="modal == 'add-' + attr"
@shown="$refs.upload.focus()" @cancel="$emit('close')">
<input name="photo" type="file" ref="upload" @change="onOk"
:accept="attr == 'jpegPhoto' ? 'image/jpeg' : 'image/*'" />
</modal>
</template>
<script>
import Modal from '../Modal.vue';
export default {
export default {
name: 'AddPhotoDialog',
name: 'AddPhotoDialog',
props: {
dn: {
type: String,
required: true,
},
id: {
type: String,
required: true,
},
attr: {
type: String,
required: true,
}
},
inject: [ 'xhr' ],
methods: {
init: function() {
document.getElementById('add-photo').focus();
components: {
Modal,
},
// add an image
done: async function(evt) {
if (!evt.target.files) return;
const fd = new FormData();
fd.append("blob", evt.target.files[0])
const data = await this.xhr({
url: 'api/blob/' + this.attr + '/0/' + this.dn,
method: 'PUT',
data: fd,
binary: true,
});
if (data) this.$emit('select-dn', this.dn, data.changed);
this.$bvModal.hide('upload-' + this.attr);
props: {
dn: String,
attr: {
type: String,
validator: value => ['jpegPhoto', 'thumbnailPhoto' ].includes(value),
},
modal: String,
},
},
}
model: {
prop: 'modal',
event: 'close',
},
inject: [ 'xhr' ],
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.xhr({
url: 'api/blob/' + this.attr + '/0/' + this.dn,
method: 'PUT',
data: fd,
binary: true,
});
if (data) {
this.$emit('close');
this.$emit('ok', this.dn, data.changed);
}
},
},
}
</script>

View File

@ -1,79 +1,72 @@
<template>
<b-modal id="copy-entry" title="Copy entry"
@show="reset" @shown="init" @ok="done" @hidden="$emit('update-form')">
<modal title="Copy entry" :open="modal == 'copy-entry'"
@show="init" @shown="$refs.dn.focus()"
@ok="onOk" @cancel="$emit('close')">
<div class="error" v-if="error">{{ error }}</div>
<input id="copy-to-dn" v-model="dn" class="mb-3 form-control"
placeholder="New DN" @keyup.enter="done" />
</b-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" />
</div>
</modal>
</template>
<script>
import Modal from '../Modal.vue';
export default {
export default {
name: 'CopyEntryDialog',
name: 'CopyEntryDialog',
props: {
entry: {
type: Object,
required: true,
},
},
data: function() {
return {
dn: this.entry.meta.dn,
error: '',
}
},
methods: {
reset: function() {
this.dn = this.error = '';
components: {
Modal,
},
init: function() {
document.getElementById('copy-to-dn').focus();
this.dn = this.entry.meta.dn;
props: {
entry: Object,
modal: String,
},
// Load copied entry into the editor
done: function(evt) {
model: {
prop: 'modal',
event: 'close',
},
if (!this.dn || this.dn == this.entry.meta.dn) {
evt.preventDefault();
this.error = 'This DN already exists';
return;
data: function() {
return {
dn: undefined,
error: '',
}
const parts = this.dn.split(','),
rdnpart = parts[0].split('='),
rdn = rdnpart[0];
if (rdnpart.length != 2) {
evt.preventDefault();
this.error = 'Invalid RDN: ' + parts[0];
return;
}
this.$set(this.entry.attrs, rdn, [rdnpart[1]]);
this.$set(this.entry.meta, 'dn', this.dn);
this.$set(this.entry.meta, 'isNew', true);
this.$bvModal.hide('copy-entry');
this.$emit('select-dn');
},
},
}
</script>
methods: {
<style scoped>
div.error {
color: red;
font-size: small;
margin-bottom: 0.3em;
init: function() {
this.error = '';
this.dn = this.entry.meta.dn;
},
// 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('close');
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);
},
},
}
</style>
</script>

View File

@ -1,13 +1,14 @@
<template>
<b-modal id="confirm" title="Are you sure?" @show="reset" @shown="init" @ok="done"
cancel-variant="primary" ok-variant="danger">
<modal title="Are you sure?" :open="modal == 'delete-entry'"
cancel-variant="primary" ok-variant="danger"
@show="init" @ok="onOk" @cancel="$emit('close')">
<p class="strong">This action is irreversible.</p>
<div v-if="subtree && subtree.length">
<p class="red">The following child nodes will be also deleted:</p>
<p class="text-danger mb-2">The following child nodes will be also deleted:</p>
<div v-for="node in subtree" :key="node.dn">
<span v-for="i in node.level" class="indent" :key="i"></span>
<span v-for="i in node.level" class="ml-6" :key="i"></span>
<node-label :oc="node.structuralObjectClass">
{{ node.dn.split(',')[0] }}
</node-label>
@ -17,69 +18,49 @@
<template #modal-ok>
<i class="fa fa-trash-o fa-lg"></i> Delete
</template>
</b-modal>
</modal>
</template>
<script>
import Modal from '../Modal.vue';
import NodeLabel from '../NodeLabel.vue';
import NodeLabel from '../NodeLabel.vue';
export default {
name: 'DeleteEntryDialog',
export default {
name: 'DeleteEntryDialog',
components: {
NodeLabel,
},
props: {
dn: {
type: String,
required: true,
},
info: {
type: Function,
required: true,
},
},
inject: [ 'xhr' ],
data: function() {
return {
subtree: [],
}
},
methods: {
reset: function() {
this.subtree = [];
components: {
Modal,
NodeLabel,
},
// List subordinate elements of a DN
init: async function() {
this.subtree = await this.xhr({ url: 'api/subtree/' + this.dn});
props: {
dn: String,
modal: String,
},
done: async function() {
if (await this.xhr({ url: 'api/entry/' + this.dn, method: 'DELETE' }) !== undefined) {
this.info('Deleted: ' + this.dn);
this.$emit('select-dn', '-' + this.dn);
model: {
prop: 'modal',
event: 'close',
},
inject: [ 'xhr' ],
data: function() {
return {
subtree: [],
}
},
},
}
methods: {
// List subordinate elements to be deleted
init: async function() {
this.subtree = await this.xhr({ url: 'api/subtree/' + this.dn}) || [];
},
onOk: function() {
this.$emit('close');
this.$emit('ok', this.dn);
},
},
}
</script>
<style scoped>
.red {
color: red !important;
}
span.indent {
margin-left: 1.2em;
}
</style>

View File

@ -1,42 +1,48 @@
<template>
<b-modal id="confirm-discard" title="Are you sure?" @shown="init" @ok="done"
@cancel="$emit('select-dn')" cancel-variant="primary" ok-variant="danger">
<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('close')">
<p class="strong">All changes will be irreversibly lost.</p>
<template #modal-ok>
<i class="fa fa-trash-o fa-lg"></i> Discard
</template>
</b-modal>
</modal>
</template>
<script>
import Modal from '../Modal.vue';
export default {
export default {
name: 'DiscardEntryDialog',
name: 'DiscardEntryDialog',
props: {
dn: String,
},
data: function() {
return {
next: undefined,
}
},
methods: {
init: function() {
this.next = this.dn;
this.$emit('select-dn');
components: {
Modal,
},
done: function() {
this.$emit('replace-entry', null);
this.$emit('select-dn', this.next);
props: {
dn: String,
modal: String,
},
},
}
model: {
prop: 'modal',
event: 'close',
},
data: function() {
return {
next: undefined,
}
},
methods: {
onOk: function() {
this.$emit('close');
this.$emit('ok', this.next);
},
},
}
</script>

View File

@ -1,327 +1,348 @@
<template>
<!-- Entry editor form -->
<b-form v-if="entry" id="entry-form"
@submit.prevent="save" @reset="load(entry.meta.dn);" @focusin="onFocus">
<div v-if="entry" class="rounded border border-front/20 mb-3 mx-4 flex-auto">
<new-entry-dialog :entry="entry" :schema="schema"
@replace-entry="newEntry"
@select-dn="$emit('select-dn')" />
<copy-entry-dialog :entry="entry"
@select-dn="$emit('select-dn', $event)" />
<rename-entry-dialog :dn="entry.meta.dn" :entry="entry"
@select-dn="$emit('select-dn', $event)" />
<delete-entry-dialog :dn="entry.meta.dn" :info="showInfo"
@select-dn="$emit('select-dn', $event)" />
<discard-entry-dialog :dn="activeDn"
@replace-entry="entry = $event;"
@select-dn="$emit('select-dn', $event)" />
<!-- Modals for navigation menu -->
<new-entry-dialog v-model="modal" :entry="entry" :schema="schema" @ok="newEntry" />
<copy-entry-dialog v-model="modal" :entry="entry" @ok="newEntry" />
<rename-entry-dialog v-model="modal" :entry="entry" @ok="renameEntry" />
<delete-entry-dialog v-model="modal" :dn="entry.meta.dn" @ok="deleteEntry" />
<discard-entry-dialog v-model="modal" :dn="activeDn" @ok="discardEntry"
@shown="$emit('select-dn')" />
<password-change-dialog :entry="entry" :user="user" />
<add-photo-dialog id="upload-jpegPhoto" attr="jpegPhoto" :dn="entry.meta.dn" @select-dn="load" />
<add-photo-dialog id="upload-thumbnailPhoto" attr="thumbnailPhoto" :dn="entry.meta.dn" @select-dn="load" />
<add-attribute-dialog :entry="entry" :attributes="may" @update-form="prepareForm" />
<add-object-class-dialog :entry="entry" @update-form="prepareForm" />
<b-card id="editor">
<div>
<slot name="header">
<b-nav>
<b-nav-item v-if="entry.meta.isNew">
<node-label :dn="entry.meta.dn" :oc="structural" />
</b-nav-item>
<b-nav-item-dropdown v-else extra-toggle-classes="nav-link-custom" right class="entry-menu">
<template #button-content>
<node-label :dn="entry.meta.dn" :oc="structural" />
</template>
<b-dropdown-item v-b-modal.new-entry>Add child</b-dropdown-item>
<b-dropdown-item v-b-modal.copy-entry>Copy…</b-dropdown-item>
<b-dropdown-item v-b-modal.rename-entry>Rename…</b-dropdown-item>
<b-dropdown-item @click="ldif">Export</b-dropdown-item>
<b-dropdown-item v-b-modal.confirm><span class="red">Delete</span></b-dropdown-item>
</b-nav-item-dropdown>
</b-nav>
<span v-if="entry.meta.isNew" class="close-box control" v-b-modal.confirm-discard>⊗</span>
<span v-else class="close-box control" @click="$emit('select-dn')"></span>
</slot>
<!-- Modals for main editing area -->
<password-change-dialog v-model="modal" :entry="entry" :user="user"
@ok="changePassword" />
<add-photo-dialog v-model="modal" attr="jpegPhoto" :dn="entry.meta.dn" @ok="load" />
<add-photo-dialog v-model="modal" attr="thumbnailPhoto" :dn="entry.meta.dn" @ok="load" />
<add-object-class-dialog v-model="modal" :entry="entry" @ok="addObjectClass" />
<!-- Modals for footer -->
<add-attribute-dialog v-model="modal" :entry="entry" :attributes="may"
@ok="addAttribute" @show-modal="modal = $event;" />
<nav class="flex justify-between mb-4 border-b border-front/20">
<div v-if="entry.meta.isNew" class="py-2 ml-3">
<node-label :dn="entry.meta.dn" :oc="structural" />
</div>
<div v-else class="ml-2">
<dropdown-menu>
<template #button-content>
<node-label :dn="entry.meta.dn" :oc="structural" />
</template>
<li @click="modal = 'new-entry';" role="menuitem">Add child</li>
<li @click="modal = 'copy-entry';" role="menuitem">Copy</li>
<li @click="modal = 'rename-entry';" role="menuitem">Rename</li>
<li @click="ldif" role="menuitem">Export</li>
<li @click="modal = 'delete-entry';" class="text-danger" role="menuitem">Delete</li>
</dropdown-menu>
</div>
<table id="entry">
<form-row v-for="key in keys" class="attr" :key="key" :must="must.includes(key)"
:attr="schema.attr(key)" :meta="entry.meta" :values="entry.attrs[key]"
:changed="entry.changed.includes(key)" :structural="schema.structural" :base-dn="baseDn"
:may="may.includes(key)" @form-changed="prepareForm"
@reload-form="load" @valid="valid(key, $event)"
@display-oc="$emit('display-oc', $event)" @display-attr="$emit('display-attr', $event)" />
<tr>
<th></th>
<td>
<div class="button-bar">
<b-button type="submit" variant="primary" accesskey="s" :disabled="invalid.length != 0">Submit</b-button>
<b-button type="reset" v-if="!entry.meta.isNew">Reset</b-button>
<b-button class="right" v-if="!entry.meta.isNew" v-b-modal.add-attribute accesskey="a">
Add attribute
</b-button>
</div>
</td>
</tr>
</table>
</b-card>
</b-form>
<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('select-dn')"></div>
</nav>
<form id="entry" class="space-y-4 my-4" @submit.prevent="save"
@reset="load(entry.meta.dn)" @focusin="onFocus">
<form-row v-for="key in keys" class="attr" :key="key"
:attr="schema.attr(key)" :base-dn="baseDn" :meta="entry.meta" :values="entry.attrs[key]"
:changed="entry.changed.includes(key)" :structural="schema.structural"
:may="may.includes(key)" :must="must.includes(key)"
@display-attr="$emit('display-attr', $event)"
@display-oc="$emit('display-oc', $event)"
@form-changed="prepareForm"
@reload-form="load"
@valid="valid(key, $event)"
@show-modal="modal = $event;" />
<!-- Footer with buttons -->
<div class="flex ml-4 mt-2 space-x-4">
<div class="w-1/3"></div>
<div class="w-2/3 pl-4">
<div class="w-[90%] space-x-3">
<button type="submit" class="btn bg-primary"
accesskey="s" :disabled="invalid.length != 0">Submit</button>
<button type="reset" v-if="!entry.meta.isNew"
class="btn bg-secondary">Reset</button>
<button class="btn float-right bg-secondary" accesskey="a"
v-if="!entry.meta.isNew" @click="modal = 'add-attribute';">
Add attribute
</button>
</div>
</div>
</div>
</form>
</div>
</template>
<script>
import AddAttributeDialog from './AddAttributeDialog.vue';
import AddObjectClassDialog from './AddObjectClassDialog.vue';
import AddPhotoDialog from './AddPhotoDialog.vue';
import CopyEntryDialog from './CopyEntryDialog.vue';
import DeleteEntryDialog from './DeleteEntryDialog.vue';
import DiscardEntryDialog from './DiscardEntryDialog.vue';
import FormRow from './FormRow.vue';
import NewEntryDialog from './NewEntryDialog.vue';
import NodeLabel from '../NodeLabel.vue';
import PasswordChangeDialog from './PasswordChangeDialog.vue';
import RenameEntryDialog from './RenameEntryDialog.vue';
import { request } from '../../request.js';
import AddAttributeDialog from './AddAttributeDialog.vue';
import AddObjectClassDialog from './AddObjectClassDialog.vue';
import AddPhotoDialog from './AddPhotoDialog.vue';
import CopyEntryDialog from './CopyEntryDialog.vue';
import DeleteEntryDialog from './DeleteEntryDialog.vue';
import DiscardEntryDialog from './DiscardEntryDialog.vue';
import DropdownMenu from '../DropdownMenu.vue';
import FormRow from './FormRow.vue';
import NewEntryDialog from './NewEntryDialog.vue';
import NodeLabel from '../NodeLabel.vue';
import PasswordChangeDialog from './PasswordChangeDialog.vue';
import RenameEntryDialog from './RenameEntryDialog.vue';
import { request } from '../../request.js';
function unique(element, index, array) {
return array.indexOf(element) == index;
}
function unique(element, index, array) {
return array.indexOf(element) == index;
}
export default {
name: 'Editor',
export default {
name: 'Editor',
components: {
AddAttributeDialog,
AddObjectClassDialog,
AddPhotoDialog,
CopyEntryDialog,
DeleteEntryDialog,
DiscardEntryDialog,
FormRow,
NewEntryDialog,
NodeLabel,
PasswordChangeDialog,
RenameEntryDialog,
},
props: {
activeDn: String,
baseDn: String,
user: String,
showInfo: {
type: Function,
required: true,
components: {
AddAttributeDialog,
AddObjectClassDialog,
AddPhotoDialog,
CopyEntryDialog,
DeleteEntryDialog,
DiscardEntryDialog,
DropdownMenu,
FormRow,
NewEntryDialog,
NodeLabel,
PasswordChangeDialog,
RenameEntryDialog,
},
schema: {
type: Object,
required: true,
props: {
activeDn: String,
baseDn: String,
user: String,
showInfo: Function,
schema: Object,
},
},
model: {
prop: 'activeDn',
event: 'select-dn'
},
model: {
prop: 'activeDn',
event: 'select-dn'
},
inject: [ 'xhr' ],
inject: [ 'xhr' ],
data: function() {
return {
entry: null, // entry in editor
focused: null, // currently focused input
invalid: [],
}
},
watch: {
activeDn: function(dn) {
if (!this.entry || dn != this.entry.meta.dn) this.focused = undefined;
if (dn && this.entry && this.entry.meta.isNew) {
this.$bvModal.show('confirm-discard');
data: function() {
return {
entry: undefined, // entry in editor
focused: undefined, // currently focused input
invalid: [], // field IDs with validation errors
modal: undefined, // pop-up dialog
}
else if (dn) this.load(dn);
else if (this.entry && !this.entry.meta.isNew) this.entry = null;
}
},
methods: {
// Track focus changes
onFocus: function(evt) {
const el = evt.target;
if (el.tagName == 'INPUT' && el.id) this.focused = el.id;
},
newEntry: function(entry) {
this.entry = entry;
this.prepareForm();
},
prepareForm: function(focused) {
this.must.filter(attr => !this.entry.attrs[attr])
.forEach(attr => this.$set(this.entry.attrs, attr, ['']));
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];
}
if (!focused) focused = this.focused;
this.$nextTick(function () {
let input = focused ? document.getElementById(focused) : undefined;
if (!input) input = document.querySelector('#entry input:not([disabled])');
if (input) {
// work around annoying focus jump in OS X Safari
window.setTimeout(function() { input.focus(); }, 100);
this.focused = input.id;
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';
}
});
},
// Load an entry into the editing form
load: async function(dn, changed) {
this.invalid = [];
if (!dn || dn.startsWith('-')) {
this.entry = null;
return;
else if (dn) this.load(dn);
else if (this.entry && !this.entry.meta.isNew) this.entry = null;
}
this.entry = await this.xhr({ url: 'api/entry/' + dn });
if (!this.entry) return;
this.entry.changed = changed || [];
this.entry.meta.isNew = false;
document.title = dn.split(',')[0];
this.prepareForm();
},
// Submit the entry form via AJAX
save: async function() {
if (this.invalid.length > 0) {
methods: {
// Track focus changes
onFocus: function(evt) {
const el = evt.target;
if (el.tagName == 'INPUT' && el.id) this.focused = el.id;
},
newEntry: function(entry, dn) {
this.entry = entry;
this.$emit('select-dn');
this.prepareForm();
return;
}
},
this.entry.changed = [];
const data = await this.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' },
});
discardEntry: function(dn) {
this.entry = null;
this.$emit('select-dn', dn);
},
if (!data) return;
if (data.changed && data.changed.length) {
this.showInfo('👍 Saved changes');
}
if (this.entry.meta.isNew) {
addAttribute: function(attr) {
this.$set(this.entry.attrs, attr, ['']);
this.prepareForm(attr + '-0');
},
addObjectClass: function(oc) {
this.entry.attrs.objectClass.push(oc);
this.prepareForm();
},
prepareForm: function(focused) {
this.must.filter(attr => !this.entry.attrs[attr])
.forEach(attr => this.$set(this.entry.attrs, attr, ['']));
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];
}
if (!focused) focused = this.focused;
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;
}
});
},
// Load an entry into the editing form
load: async function(dn, changed) {
this.invalid = [];
if (!dn || dn.startsWith('-')) {
this.entry = null;
return;
}
this.entry = await this.xhr({ url: 'api/entry/' + dn });
if (!this.entry) return;
this.entry.changed = changed || [];
this.entry.meta.isNew = false;
this.$emit('select-dn', this.entry.meta.dn);
}
else this.load(this.entry.meta.dn, data.changed);
document.title = dn.split(',')[0];
this.prepareForm();
},
// Submit the entry form via AJAX
save: async function() {
if (this.invalid.length > 0) {
this.prepareForm();
return;
}
this.entry.changed = [];
const data = await this.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' },
});
if (!data) return;
if (data.changed && data.changed.length) {
this.showInfo('👍 Saved changes');
}
if (this.entry.meta.isNew) {
this.entry.meta.isNew = false;
this.$emit('select-dn', this.entry.meta.dn);
}
else this.load(this.entry.meta.dn, data.changed);
},
renameEntry: async function(rdn) {
await this.xhr({
url: 'api/rename',
method: 'POST',
data: JSON.stringify({
dn: this.entry.meta.dn,
rdn: rdn
}),
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
const dnparts = this.entry.meta.dn.split(',');
dnparts.splice(0, 1, rdn);
this.$emit('select-dn', dnparts.join(','));
},
deleteEntry: async function(dn) {
if (await this.xhr({ url: 'api/entry/' + dn, method: 'DELETE' }) !== undefined) {
this.showInfo('Deleted: ' + dn);
this.$emit('select-dn', '-' + dn);
}
},
changePassword: async function(oldPass, newPass) {
const data = await this.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' },
});
if (data !== undefined) {
this.$set(this.entry.attrs, 'userPassword', [ data ]);
this.entry.changed.push('userPassword');
}
},
// Download LDIF
ldif: async function() {
const xhr = await request({
url: 'api/ldif/' + this.entry.meta.dn,
responseType: 'blob'
}).catch(xhr => console.error(xhr));
if (!xhr) return;
const a = document.createElement("a"),
url = URL.createObjectURL(xhr.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.schema.oc(oc))
.flatMap(oc => oc ? oc.getAttributes(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);
}
},
},
// Download LDIF
ldif: async function() {
const xhr = await request({
url: 'api/ldif/' + this.entry.meta.dn,
responseType: 'blob'
}).catch(xhr => console.error(xhr));
if (!xhr) return;
computed: {
keys: function() {
let keys = Object.keys(this.entry.attrs);
keys.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
return keys;
},
const a = document.createElement("a"),
url = URL.createObjectURL(xhr.response);
a.href = url;
a.download = this.entry.meta.dn.split(',')[0].split('=')[1] + '.ldif';
document.body.appendChild(a);
a.click();
structural: function() {
const oc = this.entry.attrs.objectClass
.map(oc => this.schema.oc(oc))
.filter(oc => oc && oc.isStructural)[0];
return oc ? oc.name : '';
},
must: function() {
return this.attributes('must');
},
may: function() {
return this.attributes('may');
},
},
attributes: function(kind) {
let attrs = this.entry.attrs.objectClass
.filter(oc => oc && oc != 'top')
.map(oc => this.schema.oc(oc))
.flatMap(oc => oc ? oc.getAttributes(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.schema.oc(oc))
.filter(oc => oc && oc.isStructural)[0];
return oc ? oc.name : '';
},
must: function() {
return this.attributes('must');
},
may: function() {
return this.attributes('may');
},
},
}
}
</script>
<style scoped>
#editor {
max-width: 1024px;
}
#editor div.card-header {
padding: 0.45ex 0;
}
table#entry {
width: 100%;
}
table#entry div.button-bar {
width: 90%;
}
li.entry-menu a.nav-link {
padding-left: 8px;
}
div.button-bar button[type='submit'] {
margin-right: 0.5em;
}
</style>

View File

@ -1,383 +1,288 @@
<template>
<tr v-if="shown">
<th :class="{ required: must, optional: may, rdn: isRdn, illegal: illegal }">
<span class="clickable oc" :title="attr.desc"
<div v-if="shown" class="flex mx-4 space-x-4">
<div :class="{ required: must, optional: may, rdn: isRdn, illegal: illegal }"
class="w-1/3">
<span class="cursor-pointer oc" :title="attr.desc"
@click="$emit('display-attr', attr.name)">{{ attr }}</span>
<i v-if="changed" class="fa green fa-check"></i>
</th>
<td>
<div v-for="(val, index) in values" class="attr-value" :key="index">
<span v-if="isStructural(val)" v-b-modal.add-oc tabindex="-1"
class="clickable add-btn control" title="Add objectClass…"></span>
<span v-else-if="isAux(val)" @click="removeObjectClass(index)"
class="clickable remove-btn control" :title="'Remove ' + val"></span>
<span v-else-if="password" class="fa fa-question-circle control"
v-b-modal.change-password tabindex="-1" title="change password"></span>
<span v-else-if="attr == 'jpegPhoto' || attr == 'thumbnailPhoto'" v-b-modal.upload-photo tabindex="-1"
class="clickable add-btn control" title="Add photo…"></span>
<span v-else-if="multiple(index)" @click="addRow"
class="clickable add-btn control" title="Add row"></span>
<span v-else class="no-btn"></span>
<i v-if="changed" class="fa text-emerald-700 ml-1 fa-check"></i>
</div>
<span v-if="attr == 'jpegPhoto' || attr == 'thumbnailPhoto'" class="photo">
<img v-if="val" :src="'data:image/' + ((attr == 'jpegPhoto') ? 'jpeg' : '*') +';base64,' + val" />
<span v-if="val" class="clickable control remove-btn"
<div class="w-2/3">
<div v-for="(val, index) in values" class="attr-value" :key="index">
<span v-if="isStructural(val)" @click="$emit('show-modal', 'add-object-class')" tabindex="-1"
class="add-btn control font-bold" title="Add objectClass…"></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>
<span v-else-if="attr == 'jpegPhoto' || attr == 'thumbnailPhoto'"
@click="$emit('show-modal', 'add-jpegPhoto')" tabindex="-1"
class="add-btn control align-top" title="Add photo…"></span>
<span v-else-if="multiple(index)" @click="addRow"
class="add-btn control" title="Add row"></span>
<span v-else class="mr-5"></span>
<span v-if="attr == 'jpegPhoto' || attr == 'thumbnailPhoto'">
<img v-if="val" :src="'data:image/' + ((attr == 'jpegPhoto') ? 'jpeg' : '*') +';base64,' + val"
class="max-w-[120px] max-h-[120px] border p-[1px] inline mx-1"/>
<span v-if="val" class="control remove-btn align-top"
@click="deleteBlob(index)" title="Remove photo"></span>
</span>
<input v-else v-model="values[index]" :id="attr + '-' + index" :type="type" class="glyph"
<input v-else v-model="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="{ structural: isStructural(val), auto: defaultValue,
illegal: illegal || duplicate(index) }"
:placeholder="placeholder" :disabled="disabled"
:title="equality == 'generalizedTimeMatch' ? dateString(val) : ''"
@keyup="search" @keyup.esc="query = ''" @focusin="query = ''" />
<i v-if="attr == 'objectClass'" class="clickable fa fa-info-circle"
<i v-if="attr == 'objectClass'" class="cursor-pointer fa fa-info-circle"
@click="$emit('display-oc', val)"></i>
</div>
<search-results v-if="completable" @select-dn="complete"
:for="elementId" :query="query" label="dn" placement="topleft" :shorten="baseDn" />
<div v-if="hint" class="hint">{{ hint }}</div>
</td>
</tr>
<div v-if="hint" class="text-xs pl-2 opacity-70">{{ hint }}</div>
</div>
</div>
</template>
<script>
function unique(element, index, array) {
return array.indexOf(element) == index;
}
function unique(element, index, array) {
return array.indexOf(element) == index;
}
import SearchResults from '../SearchResults.vue';
import SearchResults from '../SearchResults.vue';
export default {
name: 'FormRow',
export default {
components: { SearchResults },
components: { SearchResults },
name: 'FormRow',
props: {
attr: {
type: Object,
required: true,
props: {
attr: Object,
values: Array,
structural: Array,
meta: Object,
must: Boolean,
may: Boolean,
changed: Boolean,
baseDn: String,
},
values: {
type: Array,
required: true,
},
inject: [ 'xhr' ],
structural: {
type: Array,
required: true,
},
data: function() {
return {
valid: undefined,
meta: {
type: Object,
required: true,
},
// Numeric ID ranges
idRanges:
[ 'uidNumber', 'gidNumber' ],
must: {
type: Boolean,
required: true,
},
// Range auto-completion
autoFilled: null,
hint: '',
may: {
type: Boolean,
required: true,
},
changed: Boolean,
baseDn: String,
},
inject: [ 'xhr' ],
data: function() {
return {
valid: undefined,
// Numeric ID ranges
idRanges:
[ 'uidNumber', 'gidNumber' ],
// Range auto-completion
autoFilled: null,
hint: '',
// DN search
query: '',
elementId: undefined,
}
},
watch: {
valid: function(ok) {
this.$emit('valid', ok);
}
},
created: async function() {
if (this.disabled
|| !this.idRanges.includes(this.attr.name)
|| this.values.length != 1
|| this.values[0]) return;
const range = await this.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.$set(this.values, 0, this.autoFilled);
},
mounted: function() { this.validate(); },
updated: function() { this.validate(); },
methods: {
validate: function() {
this.valid = !this.missing
&& (!this.illegal || this.empty)
&& this.values.every(unique);
},
// Add an empty row in the entry form
addRow: function() {
if (!this.values.includes('')) this.values.push('');
this.$emit('form-changed', this.attr.name + '-' + (this.values.length -1));
},
// Remove a row from the entry form
removeObjectClass: function(index) {
const removedOc = this.values.splice(index, 1)[0],
aux = this.meta.aux.filter(oc => oc < removedOc);
this.meta.aux.splice(aux.length, 0, removedOc);
this.$emit('form-changed');
},
// 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');
// DN search
query: '',
elementId: undefined,
}
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.structural.includes(val);
watch: {
valid: function(ok) {
this.$emit('valid', ok);
}
},
// Is the given value an auxillary object class?
isAux: function(val) {
return this.attr.name == 'objectClass' && !this.structural.includes(val);
},
created: async function() {
if (this.disabled
|| !this.idRanges.includes(this.attr.name)
|| this.values.length != 1
|| this.values[0]) return;
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];
this.$set(this.values, index, dn);
this.query = '';
},
// remove an image
deleteBlob: async function(index) {
const data = await this.xhr({
method: 'DELETE',
url: 'api/blob/' + this.attr.name + '/' + index + '/' + this.meta.dn,
});
const range = await this.xhr({ url: 'api/range/' + this.attr.name });
if (!range) return;
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);
this.hint = range.min == range.max
? '> ' + range.min
: '\u2209 (' + range.min + " - " + range.max + ')';
this.autoFilled = new String(range.next);
this.$set(this.values, 0, this.autoFilled);
},
equality: function() { return this.attr.getField('equality'); },
password: function() { return this.attr.name == 'userPassword'; },
mounted: function() { this.validate(); },
updated: function() { this.validate(); },
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.meta.isNew && (this.password || this.binary));
methods: {
validate: function() {
this.valid = !this.missing
&& (!this.illegal || this.empty)
&& this.values.every(unique);
},
// Add an empty row in the entry form
addRow: function() {
if (!this.values.includes('')) this.values.push('');
this.$emit('form-changed', this.attr.name + '-' + (this.values.length -1));
},
// Remove a row from the entry form
removeObjectClass: function(index) {
const removedOc = this.values.splice(index, 1)[0],
aux = this.meta.aux.filter(oc => oc < removedOc);
this.meta.aux.splice(aux.length, 0, removedOc);
this.$emit('form-changed');
},
// 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.structural.includes(val);
},
// Is the given value an auxillary object class?
isAux: function(val) {
return this.attr.name == 'objectClass' && !this.structural.includes(val);
},
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];
this.$set(this.values, index, dn);
this.query = '';
},
// remove an image
deleteBlob: async function(index) {
const data = await this.xhr({
method: 'DELETE',
url: 'api/blob/' + this.attr.name + '/' + index + '/' + this.meta.dn,
});
if (data) this.$emit('reload-form', this.meta.dn, data.changed);
},
},
completable: function() {
return this.elementId && this.equality == 'distinguishedNameMatch';
computed: {
shown: function() {
return (this.attr.name == 'jpegPhoto' || this.attr.name == 'thumbnailPhoto')
|| (!this.attr.no_user_mod && !this.binary);
},
equality: function() { return this.attr.getField('equality'); },
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.meta.isNew && (this.password || this.binary));
},
completable: function() {
return this.elementId && this.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]; },
// Guess the <input> type for an attribute
type: function() {
if (this.password) return 'password';
if (this.equality == 'integerMatch') return 'number';
return 'text';
},
defaultValue: function() {
return this.values.length == 1 && this.values[0] == this.autoFilled;
},
empty: function() { return this.values.every(value => !value.trim()); },
missing: function() { return this.empty && this.must; },
illegal: function() { return !this.must && !this.may; }
},
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]; },
// Guess the <input> type for an attribute
type: function() {
if (this.password) return 'password';
if (this.equality == 'integerMatch') return 'number';
return 'text';
},
defaultValue: function() {
return this.values.length == 1 && this.values[0] == this.autoFilled;
},
empty: function() { return this.values.every(value => !value.trim()); },
missing: function() { return this.empty && this.must; },
illegal: function() { return !this.must && !this.may; }
},
}
}
</script>
<style scoped>
tr.attr>th, tr.attr>td {
padding-bottom: 2ex;
div.optional span.oc {
@apply text-front/70;
}
th {
font-weight: normal;
vertical-align: top;
position: relative;
top: 0.5ex;
padding-right: 0;
div.illegal, input.illegal {
@apply line-through text-danger;
}
th.optional span.oc {
color: var(--muted-fg);
}
th.illegal, input.illegal {
text-decoration: line-through red;
}
th.rdn span.oc {
div.rdn span.oc, input.structural {
font-weight: bold;
}
th i.fa-check {
margin-left: 0.2em;
.add-btn, .remove-btn, .fa-info-circle, .fa-question-circle {
@apply opacity-40 hover:opacity-70 text-base;
}
input {
top: 0.2ex;
width: 90%;
padding: 0 0.5em;
border-top-width: 0;
border-left-width: 0;
border-right-width: 0;
position: relative;
color: var(--page-fg);
border-bottom: 1px solid var(--muted-bg);
background-color: var(--page-bg);
}
input:focus {
border-bottom: 1px solid var(--accent);
outline: none;
}
input.disabled {
border-bottom-width: 0;
background-color: var(--body-bg);
color: var(--body-fg);
}
div.attr-value {
margin: 0.2em 0;
}
span.photo img {
max-width: 120px;
max-height: 120px;
border: 1px solid #CCC;
padding: 2px;
margin: 0px 0.4em;
}
span.no-btn {
margin-right: 1.1em;
}
.add-btn, .remove-btn {
top: -0.05em;
font-size: 110%;
vertical-align: top;
position: relative;
}
td i.fa {
opacity: 0.4;
margin-right: 0.1em;
position: relative;
top: 0.3ex;
}
td i.fa:hover {
opacity: 0.7;
}
input.structural {
font-weight: bold;
}
.hint {
font-size: x-small;
padding-left: 8px;
opacity: 0.7;
input.disabled, input:disabled {
@apply border-b-0;
}
input.auto {
color: var(--accent);
@apply text-accent;
}
div.rdn span.oc::after {
content: ' (rdn)';
font-weight: 200;
}
</style>

View File

@ -1,103 +1,107 @@
<template>
<b-modal id="new-entry" title="New entry"
@show="reset" @shown="init" @ok="done" @hidden="$emit('update-form')">
<modal title="New entry" :open="modal == 'new-entry'"
@ok="onOk" @cancel="$emit('close')"
@show="init" @shown="$refs.oc.focus()">
<b-form-group label="Object class:" label-for="newoc">
<b-form-select id="new-oc" v-model="objectClass" class="mb-3"
:options="schema.structural">
</b-form-select>
</b-form-group>
<label>Object class:
<select ref="oc" v-model="objectClass">
<option v-for="cls in schema.structural">
{{ cls }}
</option>
</select>
</label>
<b-form-group label="RDN attribute:" label-for="new-rdn" v-if="objectClass">
<b-form-select id="newrdn" v-model="rdn" :options="rdns()" class="mb-3" />
</b-form-group>
<label v-if="objectClass">RDN attribute:
<select v-model="rdn">
<option v-for="rdn in rdns()">
{{ rdn }}
</option>
</select>
</label>
<input v-if="objectClass" class="form-control mb-3" v-model="name" id="new-name"
placeholder="RDN value" @keyup.enter="done" />
</b-modal>
<input v-if="objectClass" v-model="name"
placeholder="RDN value" @keyup.enter="onOk" />
</modal>
</template>
<script>
import Modal from '../Modal.vue';
export default {
export default {
name: 'NewEntryDialog',
name: 'NewEntryDialog',
props: {
entry: {
type: Object,
required: true,
},
schema: {
type: Object,
required: true,
},
},
data: function() {
return {
name: null,
rdn: null,
objectClass: null,
}
},
methods: {
reset: function() {
this.name = this.rdn = this.objectClass = null;
components: {
Modal,
},
init: function() {
document.getElementById('new-oc').focus();
props: {
entry: Object,
schema: Object,
modal: String,
},
// Create a new entry in the main editor
done: function(evt) {
if (!this.objectClass || !this.rdn || !this.name) {
evt.preventDefault();
return;
model: {
prop: 'modal',
event: 'close',
},
data: function() {
return {
objectClass: null,
rdn: null,
name: null,
}
},
const entry = {
meta: {
dn: this.rdn + '=' + this.name + ',' + this.entry.meta.dn,
aux: [],
required: [],
binary: [],
hints: {},
autoFilled: [],
isNew: true,
},
attrs: {
objectClass: [ this.objectClass ].concat(
this.oc.superClasses
.filter(oc => !oc.isStructural && oc.kind != 'abstract')
.map(oc => oc.name)),
},
changed: [],
};
methods: {
init: function() {
this.objectClass = this.rdn = this.name = null;
},
// Create a new entry in the main editor
onOk: function() {
if (!this.objectClass || !this.rdn || !this.name) {
return;
}
this.$emit('close');
const entry = {
meta: {
dn: this.rdn + '=' + this.name + ',' + this.entry.meta.dn,
aux: [],
required: [],
binary: [],
hints: {},
autoFilled: [],
isNew: true,
},
attrs: {
objectClass: [ this.objectClass ].concat(
this.oc.superClasses
.filter(oc => !oc.isStructural && oc.kind != 'abstract')
.map(oc => oc.name)),
},
changed: [],
};
entry.attrs[this.rdn] = [this.name];
this.$emit('ok', entry);
},
entry.attrs[this.rdn] = [this.name];
this.$bvModal.hide('new-entry');
this.$emit('replace-entry', entry);
this.$emit('select-dn')
// Choice list of RDN attributes for a new entry
rdns: function() {
if (!this.objectClass) return [];
const ocs = this.oc.getAttributes('must');
if (ocs.length == 1) this.rdn = ocs[0];
return ocs;
},
},
// Choice list of RDN attributes for a new entry
rdns: function() {
if (!this.objectClass) return [];
const ocs = this.oc.getAttributes('must');
if (ocs.length == 1) this.rdn = ocs[0];
return ocs;
},
},
computed: {
oc: function() {
return this.schema.oc(this.objectClass);
computed: {
oc: function() {
return this.schema.oc(this.objectClass);
},
},
},
}
}
</script>

View File

@ -1,134 +1,109 @@
<template>
<b-modal id="change-password" title="Change / verify password"
@show="reset" @shown="init" @ok="done" @hidden="$emit('update-form')">
<modal title="Change / verify password" :open="modal == 'change-password'"
@show="init" @shown="focus" @ok="onOk"
@cancel="$emit('close')" @hidden="$emit('update-form')">
<div v-if="oldExists">
<small >{{ currentUser ? 'Required' : 'Optional' }}</small>
<i v-if="passwordOk !== undefined" class="fa"
:class="passwordOk ? 'green fa-check-circle' : 'red fa-times-circle'"></i>
<i v-if="passwordOk !== undefined" class="fa ml-2"
:class="passwordOk ? 'text-emerald-700 fa-check-circle' : 'text-danger fa-times-circle'"></i>
<input id="old-password" v-model="oldPassword" class="mb-3 form-control"
<input ref="old" v-model="oldPassword"
placeholder="Old password" type="password" @change="check" />
</div>
<input id="new-password" v-model="newPassword" class="mb-3 form-control"
placeholder="New password" type="password" />
<input ref="changed" v-model="newPassword" placeholder="New password" type="password" />
<input v-model="repeated" class="mb-3 form-control"
:class="{ red: repeated && !passwordsMatch }"
placeholder="Repeat new password" type="password" @keyup.enter="done" />
</b-modal>
<input v-model="repeated" :class="{ 'text-danger': repeated && !passwordsMatch }"
placeholder="Repeat new password" type="password" @keyup.enter="onOk" />
</modal>
</template>
<script>
import Modal from '../Modal.vue';
export default {
export default {
name: 'PasswordChangeDialog',
name: 'PasswordChangeDialog',
props: {
entry: {
type: Object,
required: true
components: {
Modal,
},
user: {
type: String,
required: true,
},
},
inject: [ 'xhr' ],
data: function() {
return {
oldPassword: '',
newPassword: '',
repeated: '',
passwordOk: undefined,
}
},
methods: {
reset: function() {
this.oldPassword = this.newPassword = this.repeated = '';
this.passwordOk = undefined;
props: {
entry: Object,
user: String,
modal: String,
},
init: function() {
document.getElementById(this.oldExists ? 'old-password' : 'new-password').focus();
model: {
prop: 'modal',
event: 'close',
},
// 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) {
inject: [ 'xhr' ],
data: function() {
return {
oldPassword: '',
newPassword: '',
repeated: '',
passwordOk: undefined,
}
},
methods: {
init: function() {
this.oldPassword = this.newPassword = this.repeated = '';
this.passwordOk = undefined;
return;
}
this.passwordOk = await this.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' },
});
},
},
done: async function(evt) {
// 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)) {
evt.preventDefault();
return;
}
const data = await this.xhr({
focus: function() {
if (this.oldExists) this.$refs.old.focus();
else this.$refs.changed.focus();
},
// 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.xhr({
url: 'api/entry/password/' + this.entry.meta.dn,
method: 'POST',
data: JSON.stringify({ old: this.oldPassword, new1: this.newPassword }),
data: JSON.stringify({ check: this.oldPassword }),
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
},
if (data !== undefined) {
this.$set(this.entry.attrs, 'userPassword', [ data ]);
this.entry.changed.push('userPassword');
this.$bvModal.hide('change-password');
}
},
},
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;
computed: {
currentUser: function() {
return this.user == this.entry.meta.dn;
this.$emit('close');
this.$emit('ok', this.oldPassword, this.newPassword);
},
},
// Verify that the new password is repeated correctly
passwordsMatch: function() {
return this.newPassword && this.newPassword == this.repeated;
},
computed: {
currentUser: function() {
return this.user == this.entry.meta.dn;
},
oldExists: function() {
return this.entry.attrs.userPassword
&& this.entry.attrs.userPassword[0] != '';
},
// 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] != '';
},
}
}
}
</script>
<style scoped>
#change-password input {
display: inline;
}
#change-password i {
margin-left: 0.5em;
}
.red {
color: red !important;
}
</style>

View File

@ -1,91 +1,69 @@
<template>
<b-modal id="rename-entry" title="Rename entry"
@show="reset" @shown="init" @ok="done" @hidden="$emit('update-form')">
<modal title="Rename entry" :open="modal == 'rename-entry'"
@ok="onOk" @cancel="$emit('close')"
@show="init" @shown="$refs.rdn.focus()">
<b-form-group label="New RDN attribute:" label-for="rename-rdn">
<b-form-select id="rdn" v-model="rdn" :options="rdns" class="mb-3"
@keydown.native.enter.prevent="done" />
</b-form-group>
</b-modal>
<label>New RDN attribute:
<select ref="rdn" v-model="rdn" @keyup.enter="onOk">
<option v-for="rdn in rdns">{{ rdn }}</option>
</select>
</label>
</modal>
</template>
<script>
import Modal from '../Modal.vue';
export default {
export default {
name: 'RenameEntryDialog',
name: 'RenameEntryDialog',
props: {
entry: {
type: Object,
required: true,
},
dn: {
type: String,
required: true,
},
},
inject: [ 'xhr' ],
data: function() {
return {
rdn: undefined,
}
},
methods: {
reset: function() {
this.rdn = undefined;
components: {
Modal,
},
init: function() {
document.getElementById('rdn').focus();
if (this.rdns.length == 1) this.rdn = this.rdns[0];
props: {
entry: Object,
modal: String,
},
done: async function(evt) {
const rdnAttr = this.entry.attrs[this.rdn];
if (!rdnAttr || !rdnAttr[0]) {
evt.preventDefault();
return;
model: {
prop: 'modal',
event: 'close',
},
data: function() {
return {
rdn: undefined,
}
},
methods: {
init: function() {
this.rdn = this.rdns.length == 1 ? this.rdns[0] : undefined;
},
onOk: async function() {
const rdnAttr = this.entry.attrs[this.rdn];
if (!rdnAttr || !rdnAttr[0]) {
return;
}
this.$emit('close');
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));
},
const rdn = this.rdn + '=' + rdnAttr[0],
xhr = await this.xhr({
url: 'api/rename',
method: 'POST',
data: JSON.stringify({
dn: this.dn,
rdn: rdn
}),
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
if (!xhr) {
evt.preventDefault();
return;
}
const dnparts = this.dn.split(',');
dnparts.splice(0, 1, rdn);
this.$emit('select-dn', dnparts.join(','));
this.$bvModal.hide('rename-entry');
},
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));
},
}
}
}
</script>

View File

@ -1,11 +1,9 @@
<template>
<b-card v-if="attr" :title="attr.names.join(', ')" title-tag="strong">
<slot name="header">
<div class="header">{{ attr.desc }}</div>
<span class="control close-box" @click="$emit('display-attr')"></span>
</slot>
<card :title="attr.names.join(', ')" @close="$emit('display-attr')">
<div class="header">{{ attr.desc }}</div>
<ul>
<ul class="list-disc mt-2">
<template v-for="(val, key) in attr">
<li :key="key" v-if="val && hiddenFields.indexOf(key) == -1">
{{ key }}: {{ val }}
@ -13,45 +11,38 @@
</template>
</ul>
<div v-if="attr.sup.length > 0">
Superclasses:
<ul>
<div v-if="attr.sup.length > 0" class="mt-2"><i>Parents:</i>
<ul class="list-disc mt-2">
<li v-for="name in attr.sup" :key="name">
<span class="clickable u" @click="$emit('display-attr', name)">{{ name }}</span>
<span class="cursor-pointer" @click="$emit('display-attr', name)">{{ name }}</span>
</li>
</ul>
</div>
</b-card>
</card>
</template>
<script>
import { LdapSchema } from './schema.js';
import Card from '../Card.vue';
import { LdapSchema } from './schema.js';
export default {
name: 'AttributeCard',
export default {
components: {
Card,
},
name: 'AttributeCard',
props: {
attr: LdapSchema.Attribute,
},
props: {
attr: {
type: LdapSchema.Attribute,
required: true,
}
},
data: function() {
return {
hiddenFields: [ // not shown in schema panel
'desc', 'name', 'names',
'no_user_mod', 'obsolete', 'oid',
'usage', 'syntax', 'sup' ]
data: function() {
return {
hiddenFields: [ // not shown in schema panel
'desc', 'name', 'names',
'no_user_mod', 'obsolete', 'oid',
'usage', 'syntax', 'sup' ]
}
}
}
}
</script>
<style scoped>
div.header {
margin-bottom: 1ex;
}
</style>

View File

@ -1,55 +1,47 @@
<template>
<b-card v-if="oc" :title="oc.name" title-tag="strong" class="oc-card">
<slot name="header">
<div class="header">{{ oc.desc }}</div>
<span class="control close-box" @click="$emit('display-oc', undefined)"></span>
</slot>
<card :title="oc.name" @close="$emit('display-oc')">
<div class="header">{{ oc.desc }}</div>
<div v-if="oc.must.length"> Required attributes:
<ul>
<li v-for="name in oc.must" :key="name">
<span class="clickable u" @click="$emit('display-attr', name)">{{ name }}</span>
</li>
</ul>
</div>
<div v-if="oc.may.length"> Optional attributes:
<ul>
<li v-for="name in oc.may" :key="name">
<span class="clickable u" @click="$emit('display-attr', name)">{{ name }}</span>
</li>
</ul>
</div>
<div v-if="oc.sup.length"> Superclasses:
<ul>
<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="clickable u" @click="$emit('display-oc', name)">{{ name }}</span>
<span class="cursor-pointer" @click="$emit('display-oc', name)">{{ name }}</span>
</li>
</ul>
</div>
</b-card>
<div v-if="oc.must.length" class="mt-2"><i>Required attributes:</i>
<ul class="list-disc">
<li v-for="name in oc.must" :key="name">
<span class="cursor-pointer" @click="$emit('display-attr', name)">{{ name }}</span>
</li>
</ul>
</div>
<div v-if="oc.may.length" class="mt-2"><i>Optional attributes:</i>
<ul class="list-disc">
<li v-for="name in oc.may" :key="name">
<span class="cursor-pointer" @click="$emit('display-attr', name)">{{ name }}</span>
</li>
</ul>
</div>
</card>
</template>
<script>
import { LdapSchema } from './schema.js';
import Card from '../Card.vue';
import { LdapSchema } from './schema.js';
export default {
name: 'ObjectClassCard',
export default {
components: {
Card,
},
name: 'ObjectClassCard',
props: {
oc: {
type: LdapSchema.ObjectClass,
required: true,
}
},
}
</script>
<style scoped>
div.header {
margin-bottom: 1ex;
props: {
oc: LdapSchema.ObjectClass,
},
}
</style>
</script>

View File

@ -1,15 +1,11 @@
"use strict";
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-vue/dist/bootstrap-vue.css';
import 'font-awesome/css/font-awesome.min.css';
import Vue from 'vue';
import BootstrapVue from 'bootstrap-vue';
import App from './App.vue';
Vue.use(BootstrapVue);
import './tailwind.css';
import 'font-awesome/css/font-awesome.min.css';
Vue.config.productionTip = false;
new Vue({

22
src/tailwind.css Normal file
View File

@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-front: 32 32 32;
--color-back: 255 255 255;
--color-accent: 23 162 184;
--color-primary: 21 101 192;
--color-secondary: 108 117 125;
--color-danger: 229 57 53;
}
@media (prefers-color-scheme: dark) {
:root {
--color-front: 255 255 255;
--color-back: 16 16 16;
}
}
}

29
tailwind.config.js Normal file
View File

@ -0,0 +1,29 @@
function withOpacityValue(variable) {
return ({ opacityValue }) => {
if (opacityValue === undefined) {
return `rgb(var(${variable}))`
}
return `rgb(var(${variable}) / ${opacityValue})`
}
}
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
accent: withOpacityValue('--color-accent'),
back: withOpacityValue('--color-back'),
danger: withOpacityValue('--color-danger'),
front: withOpacityValue('--color-front'),
primary: withOpacityValue('--color-primary'),
secondary: withOpacityValue('--color-secondary'),
}
},
},
plugins: [],
}

View File

@ -12,32 +12,15 @@ export default defineConfig({
base: './',
build: {
chunkSizeWarningLimit: 600
},
server: {
proxy: {
'/api/': {
target: 'http://127.0.0.1:5000/'
}
}
},
build: {
chunkSizeWarningLimit: 600
},
css: { // https://github.com/vitejs/vite/issues/6333#issuecomment-1003318603
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: (atRule) => {
if (atRule.name === 'charset') {
atRule.remove();
}
}
}
}
]
},
}
})