Switch to Tailwind for CSS
Refactor modals Update dependencies
This commit is contained in:
parent
62818b6c61
commit
d463a6e4ac
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"css.customData": [".vscode/tailwind.json"]
|
||||
}
|
55
.vscode/tailwind.json
vendored
Normal file
55
.vscode/tailwind.json
vendored
Normal 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 you’d 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -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.
|
||||
|
@ -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>
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
]
|
||||
],
|
||||
"vueCompilerOptions": {
|
||||
"target": 2.7
|
||||
}
|
||||
}
|
||||
|
BIN
package-lock.json
generated
BIN
package-lock.json
generated
Binary file not shown.
20
package.json
20
package.json
@ -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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
425
src/App.vue
425
src/App.vue
@ -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
23
src/components/Card.vue
Normal 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>
|
38
src/components/DropdownMenu.vue
Normal file
38
src/components/DropdownMenu.vue
Normal 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>
|
@ -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
122
src/components/Modal.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
47
src/components/Popover.vue
Normal file
47
src/components/Popover.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
10
src/main.js
10
src/main.js
@ -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
22
src/tailwind.css
Normal 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
29
tailwind.config.js
Normal 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: [],
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user