Reformat everything Vue to 4 spaces like a jerk
This commit is contained in:
parent
9ed1ebb828
commit
eb25fa0ba4
@ -7,7 +7,7 @@ indent_style = space
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{cjs, js, ts, py, json, yml, css, html, vue}]
|
||||
indent_size = 2
|
||||
indent_size = 4
|
||||
max_line_length = 120
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
|
156
package.json
156
package.json
@ -1,80 +1,86 @@
|
||||
{
|
||||
"name": "ldap-ui",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-s type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/",
|
||||
"test": "vitest",
|
||||
"tw-config": "tailwind-config-viewer -o"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/components": "^10.2.1",
|
||||
"@vueuse/core": "^10.2.1",
|
||||
"font-awesome": "^4.7.0",
|
||||
"vue": "^3.4.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.3.3",
|
||||
"@tsconfig/node20": "^20.1.2",
|
||||
"@types/node": "^20.11.10",
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "latest",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"tailwind-config-viewer": "^1.7.2",
|
||||
"tailwindcss": "^3.3",
|
||||
"npm-run-all2": "^6.1.1",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "~5.3.0",
|
||||
"vite": "^5.0.11",
|
||||
"vue-tsc": "^1.8.27",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vitest": "^0.34.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true
|
||||
"name": "ldap-ui",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-s type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/",
|
||||
"test": "vitest",
|
||||
"tw-config": "tailwind-config-viewer -o"
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/vue3-essential",
|
||||
"@vue/typescript/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
"dependencies": {
|
||||
"@vueuse/components": "^10.2.1",
|
||||
"@vueuse/core": "^10.2.1",
|
||||
"font-awesome": "^4.7.0",
|
||||
"vue": "^3.4.15"
|
||||
},
|
||||
"rules": {
|
||||
"vue/no-unused-vars": "error",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"args": "all",
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrors": "all",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"destructuredArrayIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.3.3",
|
||||
"@tsconfig/node20": "^20.1.2",
|
||||
"@types/node": "^20.11.10",
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "latest",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"tailwind-config-viewer": "^1.7.2",
|
||||
"tailwindcss": "^3.3",
|
||||
"npm-run-all2": "^6.1.1",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "~5.3.0",
|
||||
"vite": "^5.0.11",
|
||||
"vue-tsc": "^1.8.27",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vitest": "^0.34.2"
|
||||
},
|
||||
"prettier": {
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": false
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/vue3-essential",
|
||||
"@vue/typescript/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"vue/no-unused-vars": "error",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"args": "all",
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrors": "all",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"destructuredArrayIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
}
|
||||
|
266
src/App.vue
266
src/App.vue
@ -1,166 +1,208 @@
|
||||
<template>
|
||||
<div id="app" v-if="user">
|
||||
<nav-bar v-model:treeOpen="treeOpen" :dn="activeDn" :base-dn="baseDn" :user="user"
|
||||
@show-modal="modal = $event;" @select-dn="activeDn = $event;" @show-oc="oc = $event;" />
|
||||
<div id="app" v-if="user">
|
||||
<nav-bar
|
||||
v-model:treeOpen="treeOpen"
|
||||
:dn="activeDn"
|
||||
:base-dn="baseDn"
|
||||
:user="user"
|
||||
@show-modal="modal = $event"
|
||||
@select-dn="activeDn = $event"
|
||||
@show-oc="oc = $event"
|
||||
/>
|
||||
|
||||
<ldif-import-dialog v-model:modal="modal" @ok="activeDn = '-';" />
|
||||
<ldif-import-dialog v-model:modal="modal" @ok="activeDn = '-'" />
|
||||
|
||||
<div class="flex container">
|
||||
<div class="space-y-4"><!-- left column -->
|
||||
<tree-view v-model:activeDn="activeDn" v-show="treeOpen" @base-dn="baseDn = $event;" />
|
||||
<object-class-card v-model="oc" @show-attr="attr = $event;" @show-oc="oc = $event;" />
|
||||
<attribute-card v-model="attr" @show-attr="attr = $event;" />
|
||||
</div>
|
||||
|
||||
<div class="flex-auto mt-4"><!-- main editing area -->
|
||||
<transition name="fade"><!-- Notifications -->
|
||||
<div v-if="error" :class="error.cssClass"
|
||||
class="rounded mx-4 mb-4 p-3 border border-front/70 text-front/70 dark:text-back/70">
|
||||
{{ error.msg }}
|
||||
<span class="float-right control" @click="error = undefined">✖</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<entry-editor v-model:activeDn="activeDn" :user="user"
|
||||
@show-attr="attr = $event;" @show-oc="oc = $event;"/>
|
||||
</div>
|
||||
<div class="flex container">
|
||||
<div class="space-y-4">
|
||||
<!-- left column -->
|
||||
<tree-view
|
||||
v-model:activeDn="activeDn"
|
||||
v-show="treeOpen"
|
||||
@base-dn="baseDn = $event"
|
||||
/>
|
||||
<object-class-card
|
||||
v-model="oc"
|
||||
@show-attr="attr = $event"
|
||||
@show-oc="oc = $event"
|
||||
/>
|
||||
<attribute-card v-model="attr" @show-attr="attr = $event" />
|
||||
</div>
|
||||
|
||||
<div class="flex-auto mt-4">
|
||||
<!-- main editing area -->
|
||||
<transition name="fade"
|
||||
><!-- Notifications -->
|
||||
<div
|
||||
v-if="error"
|
||||
:class="error.cssClass"
|
||||
class="rounded mx-4 mb-4 p-3 border border-front/70 text-front/70 dark:text-back/70"
|
||||
>
|
||||
{{ error.msg }}
|
||||
<span
|
||||
class="float-right control"
|
||||
@click="error = undefined"
|
||||
>✖</span
|
||||
>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<entry-editor
|
||||
v-model:activeDn="activeDn"
|
||||
:user="user"
|
||||
@show-attr="attr = $event"
|
||||
@show-oc="oc = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="false">
|
||||
<!-- Not rendered, prevents color pruning -->
|
||||
<span class="text-primary bg-primary"></span>
|
||||
<span class="text-back bg-back"></span>
|
||||
<span class="text-danger bg-danger"></span>
|
||||
<span class="text-front bg-front"></span>
|
||||
<span class="text-secondary bg-secondary"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="false"><!-- Not rendered, prevents color pruning -->
|
||||
<span class="text-primary bg-primary"></span>
|
||||
<span class="text-back bg-back"></span>
|
||||
<span class="text-danger bg-danger"></span>
|
||||
<span class="text-front bg-front"></span>
|
||||
<span class="text-secondary bg-secondary"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, ref, watch } from 'vue';
|
||||
import AttributeCard from './components/schema/AttributeCard.vue';
|
||||
import EntryEditor from './components/editor/EntryEditor.vue';
|
||||
import { LdapSchema } from './components/schema/schema';
|
||||
import LdifImportDialog from './components/LdifImportDialog.vue';
|
||||
import NavBar from './components/NavBar.vue';
|
||||
import ObjectClassCard from './components/schema/ObjectClassCard.vue';
|
||||
import type { Provided } from './components/Provided';
|
||||
import TreeView from './components/TreeView.vue';
|
||||
import { onMounted, provide, ref, watch } from "vue";
|
||||
import AttributeCard from "./components/schema/AttributeCard.vue";
|
||||
import EntryEditor from "./components/editor/EntryEditor.vue";
|
||||
import { LdapSchema } from "./components/schema/schema";
|
||||
import LdifImportDialog from "./components/LdifImportDialog.vue";
|
||||
import NavBar from "./components/NavBar.vue";
|
||||
import ObjectClassCard from "./components/schema/ObjectClassCard.vue";
|
||||
import type { Provided } from "./components/Provided";
|
||||
import TreeView from "./components/TreeView.vue";
|
||||
|
||||
interface Error {
|
||||
interface Error {
|
||||
counter: number;
|
||||
cssClass: string;
|
||||
msg: string
|
||||
}
|
||||
msg: string;
|
||||
}
|
||||
|
||||
const
|
||||
// Authentication
|
||||
user = ref<string>(), // logged in user
|
||||
const // Authentication
|
||||
user = ref<string>(), // logged in user
|
||||
baseDn = ref<string>(),
|
||||
|
||||
// Components
|
||||
treeOpen = ref(true), // Is the tree visible?
|
||||
activeDn = ref<string>(), // currently active DN in the editor
|
||||
modal = ref<string>(), // modal popup
|
||||
|
||||
treeOpen = ref(true), // Is the tree visible?
|
||||
activeDn = ref<string>(), // currently active DN in the editor
|
||||
modal = ref<string>(), // modal popup
|
||||
// Alerts
|
||||
error = ref<Error>(), // status alert
|
||||
|
||||
error = ref<Error>(), // status alert
|
||||
// LDAP schema
|
||||
schema = ref<LdapSchema>(),
|
||||
oc = ref<string>(), // objectClass info in side panel
|
||||
attr = ref<string>(), // attribute info in side panel
|
||||
|
||||
oc = ref<string>(), // objectClass info in side panel
|
||||
attr = ref<string>(), // attribute info in side panel
|
||||
// Helpers for components
|
||||
provided: Provided = {
|
||||
get schema() { return schema.value; },
|
||||
showInfo,
|
||||
showException,
|
||||
showWarning,
|
||||
get schema() {
|
||||
return schema.value;
|
||||
},
|
||||
showInfo,
|
||||
showException,
|
||||
showWarning,
|
||||
};
|
||||
|
||||
provide('app', provided);
|
||||
provide("app", provided);
|
||||
|
||||
onMounted(async () => { // Runs on page load
|
||||
onMounted(async () => {
|
||||
// Runs on page load
|
||||
// Get the DN of the current user
|
||||
const whoamiResponse = await fetch('api/whoami');
|
||||
const whoamiResponse = await fetch("api/whoami");
|
||||
if (whoamiResponse.ok) {
|
||||
user.value = await whoamiResponse.json();
|
||||
user.value = await whoamiResponse.json();
|
||||
}
|
||||
|
||||
// Load the schema
|
||||
const schemaResponse = await fetch('api/schema');
|
||||
const schemaResponse = await fetch("api/schema");
|
||||
if (schemaResponse.ok) {
|
||||
schema.value = new LdapSchema(await schemaResponse.json());
|
||||
schema.value = new LdapSchema(await schemaResponse.json());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
watch(attr, (a) => { if (a) oc.value = undefined; });
|
||||
watch(oc, (o) => { if (o) attr.value = undefined; });
|
||||
watch(attr, (a) => {
|
||||
if (a) oc.value = undefined;
|
||||
});
|
||||
watch(oc, (o) => {
|
||||
if (o) attr.value = undefined;
|
||||
});
|
||||
|
||||
// Display an info popup
|
||||
function showInfo(msg: string) {
|
||||
error.value = { counter: 5, cssClass: 'bg-emerald-300', msg: '' + msg };
|
||||
setTimeout(() => { error.value = undefined; }, 5000);
|
||||
}
|
||||
|
||||
// Flash a warning popup
|
||||
function showWarning(msg: string) {
|
||||
error.value = { counter: 10, cssClass: 'bg-amber-200', msg: '⚠️ ' + msg };
|
||||
setTimeout(() => { error.value = undefined; }, 10000);
|
||||
}
|
||||
|
||||
// Report an error
|
||||
function showError(msg: string) {
|
||||
error.value = { counter: 60, cssClass: 'bg-red-300', msg: '⛔ ' + msg };
|
||||
setTimeout(() => { error.value = undefined; }, 60000);
|
||||
}
|
||||
// Display an info popup
|
||||
function showInfo(msg: string) {
|
||||
error.value = { counter: 5, cssClass: "bg-emerald-300", msg: "" + msg };
|
||||
setTimeout(() => {
|
||||
error.value = undefined;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showException(msg: string) {
|
||||
const span = document.createElement('span');
|
||||
// Flash a warning popup
|
||||
function showWarning(msg: string) {
|
||||
error.value = { counter: 10, cssClass: "bg-amber-200", msg: "⚠️ " + msg };
|
||||
setTimeout(() => {
|
||||
error.value = undefined;
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Report an error
|
||||
function showError(msg: string) {
|
||||
error.value = { counter: 60, cssClass: "bg-red-300", msg: "⛔ " + msg };
|
||||
setTimeout(() => {
|
||||
error.value = undefined;
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
function showException(msg: string) {
|
||||
const span = document.createElement("span");
|
||||
span.innerHTML = msg.replace("\n", " ");
|
||||
const titles = span.getElementsByTagName('title');
|
||||
const titles = span.getElementsByTagName("title");
|
||||
for (let i = 0; i < titles.length; ++i) {
|
||||
span.removeChild(titles[i]);
|
||||
span.removeChild(titles[i]);
|
||||
}
|
||||
let text = '';
|
||||
const headlines = span.getElementsByTagName('h1');
|
||||
let text = "";
|
||||
const headlines = span.getElementsByTagName("h1");
|
||||
for (let i = 0; i < headlines.length; ++i) {
|
||||
text = text + headlines[i].textContent + ': ';
|
||||
span.removeChild(headlines[i]);
|
||||
text = text + headlines[i].textContent + ": ";
|
||||
span.removeChild(headlines[i]);
|
||||
}
|
||||
showError(text + ' ' + span.textContent);
|
||||
}
|
||||
showError(text + " " + span.textContent);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.control {
|
||||
.control {
|
||||
@apply opacity-70 hover:opacity-90 cursor-pointer select-none leading-none pt-1 pr-1;
|
||||
}
|
||||
}
|
||||
|
||||
button, .btn, [type="button"] {
|
||||
button,
|
||||
.btn,
|
||||
[type="button"] {
|
||||
@apply px-3 py-2 rounded text-back dark:text-front font-medium outline-none;
|
||||
}
|
||||
}
|
||||
|
||||
button.btn {
|
||||
button.btn {
|
||||
@apply border-solid border-back border-2 focus:border-primary dark:focus:border-front;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2IDEwIj4KICA8cG9seWdvbiBmaWxsPSJncmF5IiBwb2ludHM9IjEuNDEgNC42NyAyLjQ4IDMuMTggMy41NCA0LjY3IDEuNDEgNC42NyIgLz4KICA8cG9seWdvbiBmaWxsPSJncmF5IiBwb2ludHM9IjMuNTQgNS4zMyAyLjQ4IDYuODIgMS40MSA1LjMzIDMuNTQgNS4zMyIgLz4KPC9zdmc+) no-repeat right;
|
||||
select {
|
||||
background: url(assets/gray_bg.svg) no-repeat right;
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.glyph {
|
||||
.glyph {
|
||||
font-family: sans-serif, FontAwesome;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
4
src/assets/gray_bg.svg
Normal file
4
src/assets/gray_bg.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 6 10">
|
||||
<polygon fill="gray" points="1.41 4.67 2.48 3.18 3.54 4.67 1.41 4.67" />
|
||||
<polygon fill="gray" points="3.54 5.33 2.48 6.82 1.41 5.33 3.54 5.33" />
|
||||
</svg>
|
After Width: | Height: | Size: 249 B |
@ -1,47 +1,59 @@
|
||||
<template>
|
||||
<modal title="Import" :open="modal == 'ldif-import'" ok-title="Import"
|
||||
@show="init" @ok="onOk" @cancel="emit('update:modal')">
|
||||
<textarea v-model="ldifData" id="ldif-data" placeholder="Paste or upload LDIF"></textarea>
|
||||
<input type="file" @change="upload" accept=".ldif" />
|
||||
</modal>
|
||||
<modal
|
||||
title="Import"
|
||||
:open="modal == 'ldif-import'"
|
||||
ok-title="Import"
|
||||
@show="init"
|
||||
@ok="onOk"
|
||||
@cancel="emit('update:modal')"
|
||||
>
|
||||
<textarea
|
||||
v-model="ldifData"
|
||||
id="ldif-data"
|
||||
placeholder="Paste or upload LDIF"
|
||||
></textarea>
|
||||
<input type="file" @change="upload" accept=".ldif" />
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from 'vue';
|
||||
import Modal from './ui/Modal.vue';
|
||||
import type { Provided } from './Provided';
|
||||
import { inject, ref } from "vue";
|
||||
import Modal from "./ui/Modal.vue";
|
||||
import type { Provided } from "./Provided";
|
||||
|
||||
const
|
||||
app = inject<Provided>('app'),
|
||||
ldifData = ref(''),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
const app = inject<Provided>("app");
|
||||
const ldifData = ref("");
|
||||
const emit = defineEmits(["ok", "update:modal"]);
|
||||
|
||||
defineProps({ modal: String });
|
||||
|
||||
function init() {
|
||||
ldifData.value = '';
|
||||
}
|
||||
|
||||
// Load LDIF from file
|
||||
function upload(evt: Event) {
|
||||
defineProps({ modal: String });
|
||||
|
||||
function init() {
|
||||
ldifData.value = "";
|
||||
}
|
||||
|
||||
// Load LDIF from file
|
||||
function upload(evt: Event) {
|
||||
const target = evt.target as HTMLInputElement,
|
||||
files = target.files as FileList,
|
||||
file = files[0],
|
||||
reader = new FileReader();
|
||||
files = target.files as FileList,
|
||||
file = files[0],
|
||||
reader = new FileReader();
|
||||
|
||||
reader.onload = function() {
|
||||
ldifData.value = reader.result as string;
|
||||
target.value = '';
|
||||
}
|
||||
reader.onload = function () {
|
||||
ldifData.value = reader.result as string;
|
||||
target.value = "";
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
// Import LDIF
|
||||
async function onOk() {
|
||||
}
|
||||
|
||||
// Import LDIF
|
||||
async function onOk() {
|
||||
if (!ldifData.value) return;
|
||||
|
||||
emit('update:modal');
|
||||
const response = await fetch( 'api/ldif', { method: 'POST', body: ldifData.value });
|
||||
if (response.ok) emit('ok');
|
||||
}
|
||||
emit("update:modal");
|
||||
const response = await fetch("api/ldif", {
|
||||
method: "POST",
|
||||
body: ldifData.value,
|
||||
});
|
||||
if (response.ok) emit("ok");
|
||||
}
|
||||
</script>
|
||||
|
@ -1,58 +1,99 @@
|
||||
<template>
|
||||
<nav class="px-4 flex flex-col md:flex-row flex-wrap justify-between mt-0 py-1 bg-primary/40">
|
||||
<div class="flex items-center">
|
||||
<i class="cursor-pointer glyph fa-bars fa-lg pt-1 mr-4 md:hidden" @click="collapsed = !collapsed"></i>
|
||||
|
||||
<i class="cursor-pointer fa fa-lg mr-2" :class="treeOpen ? 'fa-list-alt' : 'fa-list-ul'"
|
||||
@click="emit('update:treeOpen', !treeOpen)"></i>
|
||||
<node-label oc="person" :dn="user" @select-dn="emit('select-dn', $event)" class="text-lg" />
|
||||
</div>
|
||||
<nav
|
||||
class="px-4 flex flex-col md:flex-row flex-wrap justify-between mt-0 py-1 bg-primary/40"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<i
|
||||
class="cursor-pointer glyph fa-bars fa-lg pt-1 mr-4 md:hidden"
|
||||
@click="collapsed = !collapsed"
|
||||
></i>
|
||||
|
||||
<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="key in app?.schema?.objectClasses.keys()"
|
||||
:key="key" @click="emit('show-oc', key)">
|
||||
{{ key }}
|
||||
</li>
|
||||
</dropdown-menu>
|
||||
<i
|
||||
class="cursor-pointer fa fa-lg mr-2"
|
||||
:class="treeOpen ? 'fa-list-alt' : 'fa-list-ul'"
|
||||
@click="emit('update:treeOpen', !treeOpen)"
|
||||
></i>
|
||||
<node-label
|
||||
oc="person"
|
||||
:dn="user"
|
||||
@select-dn="emit('select-dn', $event)"
|
||||
class="text-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="search">
|
||||
<input class="glyph px-2 py-1 rounded focus:border focus:border-front/80 outline-none text-front dark:bg-gray-800/80"
|
||||
autofocus :placeholder="' \uf002'" name="q" @focusin="input?.select();" accesskey="k"
|
||||
@keyup.esc="query = '';" id="nav-search" ref="input" />
|
||||
<search-results for="nav-search" @select-dn="query = ''; emit('select-dn', $event);"
|
||||
:shorten="baseDn" :query="query" />
|
||||
</form>
|
||||
</div>
|
||||
<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
|
||||
>
|
||||
|
||||
</nav>
|
||||
<dropdown-menu title="Schema">
|
||||
<li
|
||||
role="menuitem"
|
||||
v-for="key in app?.schema?.objectClasses.keys()"
|
||||
:key="key"
|
||||
@click="emit('show-oc', key)"
|
||||
>
|
||||
{{ key }}
|
||||
</li>
|
||||
</dropdown-menu>
|
||||
|
||||
<form @submit.prevent="search">
|
||||
<input
|
||||
class="glyph px-2 py-1 rounded focus:border focus:border-front/80 outline-none text-front dark:bg-gray-800/80"
|
||||
autofocus
|
||||
:placeholder="' \uf002'"
|
||||
name="q"
|
||||
@focusin="input?.select()"
|
||||
accesskey="k"
|
||||
@keyup.esc="query = ''"
|
||||
id="nav-search"
|
||||
ref="input"
|
||||
/>
|
||||
<search-results
|
||||
for="nav-search"
|
||||
@select-dn="
|
||||
query = '';
|
||||
emit('select-dn', $event);
|
||||
"
|
||||
:shorten="baseDn"
|
||||
:query="query"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, nextTick, ref } from 'vue';
|
||||
import DropdownMenu from './ui/DropdownMenu.vue';
|
||||
import type { Provided } from './Provided';
|
||||
import NodeLabel from './NodeLabel.vue';
|
||||
import SearchResults from './SearchResults.vue';
|
||||
|
||||
const
|
||||
app = inject<Provided>('app'),
|
||||
input = ref<HTMLInputElement | null>(null),
|
||||
query = ref(''),
|
||||
collapsed = ref(false),
|
||||
emit = defineEmits(['select-dn', 'show-modal', 'show-oc', 'update:treeOpen']);
|
||||
import { inject, nextTick, ref } from "vue";
|
||||
import DropdownMenu from "./ui/DropdownMenu.vue";
|
||||
import type { Provided } from "./Provided";
|
||||
import NodeLabel from "./NodeLabel.vue";
|
||||
import SearchResults from "./SearchResults.vue";
|
||||
|
||||
defineProps({
|
||||
const app = inject<Provided>("app"),
|
||||
input = ref<HTMLInputElement | null>(null),
|
||||
query = ref(""),
|
||||
collapsed = ref(false),
|
||||
emit = defineEmits([
|
||||
"select-dn",
|
||||
"show-modal",
|
||||
"show-oc",
|
||||
"update:treeOpen",
|
||||
]);
|
||||
|
||||
defineProps({
|
||||
baseDn: String,
|
||||
treeOpen: Boolean,
|
||||
user: String,
|
||||
});
|
||||
});
|
||||
|
||||
function search() {
|
||||
query.value = '';
|
||||
nextTick(() => { query.value = input?.value?.value || ''; });
|
||||
}
|
||||
function search() {
|
||||
query.value = "";
|
||||
nextTick(() => {
|
||||
query.value = input?.value?.value || "";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
@ -1,42 +1,46 @@
|
||||
<template>
|
||||
<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>
|
||||
<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 setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
dn: String,
|
||||
oc: String,
|
||||
import { computed } from "vue";
|
||||
const props = defineProps({
|
||||
dn: String,
|
||||
oc: String,
|
||||
}),
|
||||
|
||||
icons : {[key: string]: string} = { // OC -> icon mapping
|
||||
account: 'user',
|
||||
groupOfNames: 'users',
|
||||
groupOfURLs: 'users',
|
||||
groupOfUniqueNames: 'users',
|
||||
inetOrgPerson: 'address-book',
|
||||
krbContainer: 'lock',
|
||||
krbPrincipal: 'user-o',
|
||||
krbRealmContainer: 'globe',
|
||||
organization: 'globe',
|
||||
organizationalRole: 'android',
|
||||
organizationalUnit: 'sitemap',
|
||||
person: 'user',
|
||||
posixGroup: 'users',
|
||||
icons: { [key: string]: string } = {
|
||||
// OC -> icon mapping
|
||||
account: "user",
|
||||
groupOfNames: "users",
|
||||
groupOfURLs: "users",
|
||||
groupOfUniqueNames: "users",
|
||||
inetOrgPerson: "address-book",
|
||||
krbContainer: "lock",
|
||||
krbPrincipal: "user-o",
|
||||
krbRealmContainer: "globe",
|
||||
organization: "globe",
|
||||
organizationalRole: "android",
|
||||
organizationalUnit: "sitemap",
|
||||
person: "user",
|
||||
posixGroup: "users",
|
||||
},
|
||||
|
||||
icon = computed(() => // Get the icon for an OC
|
||||
props.oc ? ' fa-' + (icons[props.oc] || 'question')
|
||||
: 'fa-question'),
|
||||
|
||||
icon = computed(() =>
|
||||
// Get the icon for an OC
|
||||
props.oc ? " fa-" + (icons[props.oc] || "question") : "fa-question"
|
||||
),
|
||||
// Shorten a DN for readability
|
||||
label = computed(() => (props.dn || '').split(',')[0]
|
||||
.replace(/^cn=/, '')
|
||||
.replace(/^krbPrincipalName=/, '')),
|
||||
|
||||
emit = defineEmits(['select-dn']);
|
||||
label = computed(() =>
|
||||
(props.dn || "")
|
||||
.split(",")[0]
|
||||
.replace(/^cn=/, "")
|
||||
.replace(/^krbPrincipalName=/, "")
|
||||
),
|
||||
emit = defineEmits(["select-dn"]);
|
||||
</script>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { LdapSchema } from './schema/schema';
|
||||
import type { LdapSchema } from "./schema/schema";
|
||||
|
||||
export interface Provided {
|
||||
readonly schema?: LdapSchema;
|
||||
|
@ -1,84 +1,98 @@
|
||||
<template>
|
||||
<popover :open="show" @update:open="results = []">
|
||||
<li v-for="item in results" :key="item.dn" @click="done(item.dn)"
|
||||
:title="label == 'dn' ? '' : trim(item.dn)" role="menuitem">
|
||||
{{ item[label as keyof Result] }}
|
||||
</li>
|
||||
</popover>
|
||||
<popover :open="show" @update:open="results = []">
|
||||
<li
|
||||
v-for="item in results"
|
||||
:key="item.dn"
|
||||
@click="done(item.dn)"
|
||||
:title="label == 'dn' ? '' : trim(item.dn)"
|
||||
role="menuitem"
|
||||
>
|
||||
{{ item[label as keyof Result] }}
|
||||
</li>
|
||||
</popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue';
|
||||
import Popover from './ui/Popover.vue';
|
||||
import type { Provided } from './Provided';
|
||||
import { computed, inject, nextTick, ref, watch } from "vue";
|
||||
import Popover from "./ui/Popover.vue";
|
||||
import type { Provided } from "./Provided";
|
||||
|
||||
interface Result {
|
||||
interface Result {
|
||||
dn: string;
|
||||
name: string;
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
query: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
for: String,
|
||||
label: {
|
||||
type: String,
|
||||
default: 'name',
|
||||
validator: (value: string) => ['name', 'dn' ].includes(value)
|
||||
},
|
||||
shorten: String,
|
||||
silent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
}),
|
||||
const props = defineProps({
|
||||
query: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
for: String,
|
||||
label: {
|
||||
type: String,
|
||||
default: "name",
|
||||
validator: (value: string) => ["name", "dn"].includes(value),
|
||||
},
|
||||
shorten: String,
|
||||
silent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
}),
|
||||
app = inject<Provided>("app"),
|
||||
results = ref<Result[]>([]),
|
||||
show = computed(
|
||||
() =>
|
||||
props.query.trim() != "" &&
|
||||
results.value &&
|
||||
results.value.length > 1
|
||||
),
|
||||
emit = defineEmits(["select-dn"]);
|
||||
|
||||
app = inject<Provided>('app'),
|
||||
results = ref<Result[]>([]),
|
||||
show = computed(() => props.query.trim() != ''
|
||||
&& results.value && results.value.length > 1),
|
||||
emit = defineEmits(['select-dn']);
|
||||
watch(
|
||||
() => props.query,
|
||||
async (q) => {
|
||||
if (!q) return;
|
||||
|
||||
watch(() => props.query, async (q) => {
|
||||
if (!q) return;
|
||||
const response = await fetch("api/search/" + q);
|
||||
if (!response.ok) return;
|
||||
results.value = (await response.json()) as Result[];
|
||||
|
||||
const response = await fetch('api/search/' + q);
|
||||
if (!response.ok) return;
|
||||
results.value = await response.json() as Result[]
|
||||
if (results.value.length == 0 && !props.silent) {
|
||||
app?.showWarning("No search results");
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.value.length == 0 && !props.silent) {
|
||||
app?.showWarning('No search results');
|
||||
return;
|
||||
if (results.value.length == 1) {
|
||||
done(results.value[0].dn);
|
||||
return;
|
||||
}
|
||||
|
||||
results.value.sort((a: Result, b: Result) =>
|
||||
a[props.label as keyof Result]
|
||||
.toLowerCase()
|
||||
.localeCompare(b[props.label as keyof Result].toLowerCase())
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (results.value.length == 1) {
|
||||
done(results.value[0].dn);
|
||||
return;
|
||||
}
|
||||
|
||||
results.value.sort((a: Result, b: Result) =>
|
||||
a[props.label as keyof Result].toLowerCase().localeCompare(
|
||||
b[props.label as keyof Result].toLowerCase()));
|
||||
});
|
||||
|
||||
function trim(dn: string) {
|
||||
function trim(dn: string) {
|
||||
return props.shorten && props.shorten != dn
|
||||
? dn.replace(props.shorten, '…') : dn;
|
||||
}
|
||||
? dn.replace(props.shorten, "…")
|
||||
: dn;
|
||||
}
|
||||
|
||||
// use an auto-completion choice
|
||||
function done(dn: string) {
|
||||
emit('select-dn', dn);
|
||||
// use an auto-completion choice
|
||||
function done(dn: string) {
|
||||
emit("select-dn", dn);
|
||||
results.value = [];
|
||||
|
||||
nextTick(()=> {
|
||||
// Return focus to search input
|
||||
if (props.for) {
|
||||
const el = document.getElementById(props.for);
|
||||
if (el) el.focus();
|
||||
}
|
||||
nextTick(() => {
|
||||
// Return focus to search input
|
||||
if (props.for) {
|
||||
const el = document.getElementById(props.for);
|
||||
if (el) el.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,31 +1,51 @@
|
||||
<template>
|
||||
<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="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="mr-4"></span>
|
||||
<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="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="mr-4"></span>
|
||||
|
||||
<node-label :dn="item.dn" :oc="item.structuralObjectClass"
|
||||
class="tree-link whitespace-nowrap text-front/80"
|
||||
@select-dn="clicked" :class="{ active : activeDn == item.dn }">
|
||||
<span v-if="!item.level">{{ item.dn }}</span>
|
||||
</node-label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<node-label
|
||||
:dn="item.dn"
|
||||
:oc="item.structuralObjectClass"
|
||||
class="tree-link whitespace-nowrap text-front/80"
|
||||
@select-dn="clicked"
|
||||
:class="{ active: activeDn == item.dn }"
|
||||
>
|
||||
<span v-if="!item.level">{{ item.dn }}</span>
|
||||
</node-label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DN } from './schema/schema';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import NodeLabel from './NodeLabel.vue';
|
||||
import type { TreeNode } from './TreeNode';
|
||||
import { DN } from "./schema/schema";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import NodeLabel from "./NodeLabel.vue";
|
||||
import type { TreeNode } from "./TreeNode";
|
||||
|
||||
class Node implements TreeNode {
|
||||
class Node implements TreeNode {
|
||||
dn: string;
|
||||
level: number | undefined;
|
||||
hasSubordinates: boolean;
|
||||
@ -34,134 +54,139 @@
|
||||
subordinates: Node[] = [];
|
||||
|
||||
constructor(json: TreeNode) {
|
||||
this.dn = json.dn;
|
||||
this.level = this.dn.split(',').length;
|
||||
this.hasSubordinates = json.hasSubordinates;
|
||||
this.structuralObjectClass = json.structuralObjectClass;
|
||||
if (this.hasSubordinates) {
|
||||
this.subordinates = [];
|
||||
this.open = false;
|
||||
}
|
||||
this.dn = json.dn;
|
||||
this.level = this.dn.split(",").length;
|
||||
this.hasSubordinates = json.hasSubordinates;
|
||||
this.structuralObjectClass = json.structuralObjectClass;
|
||||
if (this.hasSubordinates) {
|
||||
this.subordinates = [];
|
||||
this.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
find(dn: string): Node | undefined {
|
||||
// Primitive recursive search for a DN.
|
||||
// Compares DNs a strings, without any regard for
|
||||
// distinguishedNameMatch rules.
|
||||
// See: https://ldapwiki.com/wiki/DistinguishedNameMatch
|
||||
// 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];
|
||||
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(): boolean {
|
||||
return !this.hasSubordinates || this.subordinates.length > 0;
|
||||
return !this.hasSubordinates || this.subordinates.length > 0;
|
||||
}
|
||||
|
||||
|
||||
parentDns(baseDn: string): string[] {
|
||||
const dns = [];
|
||||
for (let dn = this.dn;;) {
|
||||
dns.push(dn);
|
||||
const idx = dn.indexOf(',');
|
||||
if (idx == -1 || dn == baseDn) break;
|
||||
dn = dn.substring(idx + 1);
|
||||
const dns = [];
|
||||
for (let dn = this.dn; ; ) {
|
||||
dns.push(dn);
|
||||
const idx = dn.indexOf(",");
|
||||
if (idx == -1 || dn == baseDn) break;
|
||||
dn = dn.substring(idx + 1);
|
||||
}
|
||||
return dns;
|
||||
}
|
||||
|
||||
visible(): Node[] {
|
||||
if (!this.hasSubordinates || !this.open) return [this];
|
||||
return [this as Node].concat(
|
||||
this.subordinates.flatMap(
|
||||
node => node.visible()));
|
||||
if (!this.hasSubordinates || !this.open) return [this];
|
||||
return [this as Node].concat(
|
||||
this.subordinates.flatMap((node) => node.visible())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
activeDn: String,
|
||||
const props = defineProps({
|
||||
activeDn: String,
|
||||
}),
|
||||
tree = ref<Node>(),
|
||||
emit = defineEmits(['base-dn', 'update:activeDn']);
|
||||
emit = defineEmits(["base-dn", "update:activeDn"]);
|
||||
|
||||
onMounted(async () => {
|
||||
await reload('base');
|
||||
emit('base-dn', tree.value?.dn);
|
||||
});
|
||||
onMounted(async () => {
|
||||
await reload("base");
|
||||
emit("base-dn", tree.value?.dn);
|
||||
});
|
||||
|
||||
watch(() => props.activeDn, async (selected) => {
|
||||
if (!selected) return;
|
||||
|
||||
// Special case: Full tree reload
|
||||
if (selected == '-' || selected == 'base') {
|
||||
await reload('base');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all parents of the selected entry in the tree
|
||||
const dn = new DN(selected || tree.value!.dn);
|
||||
const hierarchy = [];
|
||||
for (let node: DN | undefined = dn; node; node = node.parent) {
|
||||
hierarchy.push(node);
|
||||
if (node.toString() == tree.value?.dn) break;
|
||||
}
|
||||
watch(
|
||||
() => props.activeDn,
|
||||
async (selected) => {
|
||||
if (!selected) return;
|
||||
|
||||
// Reveal the selected entry by opening all parents
|
||||
hierarchy.reverse();
|
||||
for (let i = 0; i < hierarchy.length; ++i) {
|
||||
const p = hierarchy[i].toString(),
|
||||
node = tree.value?.find(p);
|
||||
if (!node) break;
|
||||
if (!node.loaded) await reload(p);
|
||||
node.open = true;
|
||||
}
|
||||
// Special case: Full tree reload
|
||||
if (selected == "-" || selected == "base") {
|
||||
await reload("base");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload parent if entry was added, renamed or deleted
|
||||
if (!tree.value?.find(dn.toString())) {
|
||||
await reload(dn.parent!.toString());
|
||||
tree.value!.find(dn.parent!.toString())!.open = true;
|
||||
// Get all parents of the selected entry in the tree
|
||||
const dn = new DN(selected || tree.value!.dn);
|
||||
const hierarchy = [];
|
||||
for (let node: DN | undefined = dn; node; node = node.parent) {
|
||||
hierarchy.push(node);
|
||||
if (node.toString() == tree.value?.dn) break;
|
||||
}
|
||||
|
||||
// Reveal the selected entry by opening all parents
|
||||
hierarchy.reverse();
|
||||
for (let i = 0; i < hierarchy.length; ++i) {
|
||||
const p = hierarchy[i].toString(),
|
||||
node = tree.value?.find(p);
|
||||
if (!node) break;
|
||||
if (!node.loaded) await reload(p);
|
||||
node.open = true;
|
||||
}
|
||||
|
||||
// Reload parent if entry was added, renamed or deleted
|
||||
if (!tree.value?.find(dn.toString())) {
|
||||
await reload(dn.parent!.toString());
|
||||
tree.value!.find(dn.parent!.toString())!.open = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function clicked(dn: string) {
|
||||
);
|
||||
|
||||
async function clicked(dn: string) {
|
||||
const item = tree.value?.find(dn);
|
||||
if (item && item.hasSubordinates && !item.open) await toggle(item);
|
||||
emit('update:activeDn', dn);
|
||||
}
|
||||
emit("update:activeDn", dn);
|
||||
}
|
||||
|
||||
// Reload the subtree at entry with given DN
|
||||
async function reload(dn: string) {
|
||||
const response = await fetch('api/tree/' + dn);
|
||||
// Reload the subtree at entry with given DN
|
||||
async function reload(dn: string) {
|
||||
const response = await fetch("api/tree/" + dn);
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json() as Node[];
|
||||
data.sort((a: Node, b: Node) => a.dn.toLowerCase().localeCompare(b.dn.toLowerCase()));
|
||||
const data = (await response.json()) as Node[];
|
||||
data.sort((a: Node, b: Node) =>
|
||||
a.dn.toLowerCase().localeCompare(b.dn.toLowerCase())
|
||||
);
|
||||
|
||||
if (dn == 'base') {
|
||||
tree.value = new Node(data[0]);
|
||||
await toggle(tree.value);
|
||||
return;
|
||||
if (dn == "base") {
|
||||
tree.value = new Node(data[0]);
|
||||
await toggle(tree.value);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = tree.value?.find(dn);
|
||||
if (item) {
|
||||
item.subordinates = data.map(node => new Node(node));
|
||||
item.hasSubordinates = item.subordinates.length > 0;
|
||||
item.subordinates = data.map((node) => new Node(node));
|
||||
item.hasSubordinates = item.subordinates.length > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide / show tree elements
|
||||
async function toggle(item: Node) {
|
||||
// Hide / show tree elements
|
||||
async function toggle(item: Node) {
|
||||
if (!item.open && !item.loaded) await reload(item.dn);
|
||||
item.open = !item.open;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.active {
|
||||
.active {
|
||||
@apply text-front font-bold;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,48 +1,53 @@
|
||||
<template>
|
||||
<modal title="Add attribute" :open="modal == 'add-attribute'" :return-to="props.returnTo"
|
||||
@show="attr = undefined;" @shown="select?.focus()"
|
||||
@ok="onOk" @cancel="emit('update:modal')">
|
||||
|
||||
<select v-model="attr" ref="select" @keyup.enter="onOk">
|
||||
<option v-for="attr in available" :key="attr">{{ attr }}</option>
|
||||
</select>
|
||||
</modal>
|
||||
<modal
|
||||
title="Add attribute"
|
||||
:open="modal == 'add-attribute'"
|
||||
:return-to="props.returnTo"
|
||||
@show="attr = undefined"
|
||||
@shown="select?.focus()"
|
||||
@ok="onOk"
|
||||
@cancel="emit('update:modal')"
|
||||
>
|
||||
<select v-model="attr" ref="select" @keyup.enter="onOk">
|
||||
<option v-for="attr in available" :key="attr">{{ attr }}</option>
|
||||
</select>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import { computed, ref } from "vue";
|
||||
import Modal from "../ui/Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
attributes: { type: Array<string>, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
attributes: { type: Array<string>, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
}),
|
||||
attr = ref<string>(),
|
||||
select = ref<HTMLSelectElement | null>(null),
|
||||
available = computed(() => {
|
||||
// Choice list for new attribute selection popup
|
||||
const attrs = Object.keys(props.entry.attrs);
|
||||
return props.attributes.filter(attr => !attrs.includes(attr));
|
||||
// Choice list for new attribute selection popup
|
||||
const attrs = Object.keys(props.entry.attrs);
|
||||
return props.attributes.filter((attr) => !attrs.includes(attr));
|
||||
}),
|
||||
emit = defineEmits(['ok', 'show-modal', 'update:modal']);
|
||||
emit = defineEmits(["ok", "show-modal", "update:modal"]);
|
||||
|
||||
// Add the selected attribute
|
||||
function onOk() {
|
||||
// Add the selected attribute
|
||||
function onOk() {
|
||||
if (!attr.value) return;
|
||||
|
||||
if (attr.value == 'jpegPhoto' || attr.value == 'thumbnailPhoto') {
|
||||
emit('show-modal', 'add-' + attr.value);
|
||||
return;
|
||||
if (attr.value == "jpegPhoto" || attr.value == "thumbnailPhoto") {
|
||||
emit("show-modal", "add-" + attr.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attr.value == 'userPassword') {
|
||||
emit('show-modal', 'change-password');
|
||||
return;
|
||||
|
||||
if (attr.value == "userPassword") {
|
||||
emit("show-modal", "change-password");
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:modal');
|
||||
emit('ok', attr.value);
|
||||
}
|
||||
|
||||
emit("update:modal");
|
||||
emit("ok", attr.value);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,34 +1,40 @@
|
||||
<template>
|
||||
<modal title="Add objectClass" :open="modal == 'add-object-class'"
|
||||
@show="oc = undefined;" @shown="select?.focus()"
|
||||
@ok="onOk" @cancel="emit('update:modal')">
|
||||
|
||||
<select v-model="oc" ref="select" @keyup.enter="onOk">
|
||||
<option v-for="cls in available" :key="cls">{{ cls }}</option>
|
||||
</select>
|
||||
</modal>
|
||||
<modal
|
||||
title="Add objectClass"
|
||||
:open="modal == 'add-object-class'"
|
||||
@show="oc = undefined"
|
||||
@shown="select?.focus()"
|
||||
@ok="onOk"
|
||||
@cancel="emit('update:modal')"
|
||||
>
|
||||
<select v-model="oc" ref="select" @keyup.enter="onOk">
|
||||
<option v-for="cls in available" :key="cls">{{ cls }}</option>
|
||||
</select>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import { computed, ref } from "vue";
|
||||
import Modal from "../ui/Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
}),
|
||||
oc = ref<string>(),
|
||||
select = ref<HTMLSelectElement | null>(),
|
||||
available = computed<string[]>(() => {
|
||||
const classes = props.entry.attrs.objectClass;
|
||||
return props.entry.meta.aux.filter((cls: string) => !classes.includes(cls));
|
||||
const classes = props.entry.attrs.objectClass;
|
||||
return props.entry.meta.aux.filter(
|
||||
(cls: string) => !classes.includes(cls)
|
||||
);
|
||||
}),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
emit = defineEmits(["ok", "update:modal"]);
|
||||
|
||||
function onOk() {
|
||||
function onOk() {
|
||||
if (oc.value) {
|
||||
emit('update:modal');
|
||||
emit('ok', oc.value);
|
||||
emit("update:modal");
|
||||
emit("ok", oc.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,44 +1,54 @@
|
||||
<template>
|
||||
<modal title="Upload photo" hide-footer :return-to="returnTo"
|
||||
:open="modal == 'add-' + attr"
|
||||
@shown="upload?.focus()" @cancel="emit('update:modal')">
|
||||
|
||||
<input name="photo" type="file" ref="upload" @change="onOk"
|
||||
:accept="attr == 'jpegPhoto' ? 'image/jpeg' : 'image/*'" />
|
||||
</modal>
|
||||
<modal
|
||||
title="Upload photo"
|
||||
hide-footer
|
||||
:return-to="returnTo"
|
||||
:open="modal == 'add-' + attr"
|
||||
@shown="upload?.focus()"
|
||||
@cancel="emit('update:modal')"
|
||||
>
|
||||
<input
|
||||
name="photo"
|
||||
type="file"
|
||||
ref="upload"
|
||||
@change="onOk"
|
||||
:accept="attr == 'jpegPhoto' ? 'image/jpeg' : 'image/*'"
|
||||
/>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import { ref } from "vue";
|
||||
import Modal from "../ui/Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
attr: {
|
||||
type: String,
|
||||
validator: (value: string) => ['jpegPhoto', 'thumbnailPhoto'].includes(value),
|
||||
},
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
attr: {
|
||||
type: String,
|
||||
validator: (value: string) =>
|
||||
["jpegPhoto", "thumbnailPhoto"].includes(value),
|
||||
},
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
}),
|
||||
upload = ref<HTMLInputElement | null>(null),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
emit = defineEmits(["ok", "update:modal"]);
|
||||
|
||||
// add an image
|
||||
async function onOk(evt: Event) {
|
||||
// add an image
|
||||
async function onOk(evt: Event) {
|
||||
const target = evt.target as HTMLInputElement;
|
||||
if (!target?.files) return;
|
||||
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('blob', target.files[0])
|
||||
const response = await fetch('api/blob/' + props.attr + '/0/' + props.dn, {
|
||||
method: 'PUT',
|
||||
body: fd,
|
||||
fd.append("blob", target.files[0]);
|
||||
const response = await fetch("api/blob/" + props.attr + "/0/" + props.dn, {
|
||||
method: "PUT",
|
||||
body: fd,
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
emit('update:modal');
|
||||
emit('ok', props.dn, [props.attr]);
|
||||
emit("update:modal");
|
||||
emit("ok", props.dn, [props.attr]);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,294 +1,423 @@
|
||||
<template>
|
||||
<div v-if="attr && shown" class="flex mx-4 space-x-4">
|
||||
<div :class="{ required: must, optional: may, rdn: isRdn, illegal: illegal }"
|
||||
class="w-1/4">
|
||||
<span class="cursor-pointer oc" :title="attr.desc"
|
||||
@click="emit('show-attr', attr.name)">{{ attr }}</span>
|
||||
<i v-if="changed" class="fa text-emerald-700 ml-1 fa-check"></i>
|
||||
<div v-if="attr && shown" class="flex mx-4 space-x-4">
|
||||
<div
|
||||
:class="{
|
||||
required: must,
|
||||
optional: may,
|
||||
rdn: isRdn,
|
||||
illegal: illegal,
|
||||
}"
|
||||
class="w-1/4"
|
||||
>
|
||||
<span
|
||||
class="cursor-pointer oc"
|
||||
:title="attr.desc"
|
||||
@click="emit('show-attr', attr.name)"
|
||||
>{{ attr }}</span
|
||||
>
|
||||
<i v-if="changed" class="fa text-emerald-700 ml-1 fa-check"></i>
|
||||
</div>
|
||||
|
||||
<div class="w-3/4">
|
||||
<div v-for="(val, index) in values" :key="index">
|
||||
<span
|
||||
v-if="isStructural(val)"
|
||||
@click="emit('show-modal', 'add-object-class')"
|
||||
tabindex="-1"
|
||||
class="add-btn control font-bold"
|
||||
title="Add object class…"
|
||||
>⊕</span
|
||||
>
|
||||
<span
|
||||
v-else-if="isAux(val)"
|
||||
@click="removeObjectClass(index)"
|
||||
class="remove-btn control"
|
||||
:title="'Remove ' + val"
|
||||
>⊖</span
|
||||
>
|
||||
<span
|
||||
v-else-if="password"
|
||||
class="fa fa-question-circle control"
|
||||
@click="emit('show-modal', 'change-password')"
|
||||
tabindex="-1"
|
||||
title="change password"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="
|
||||
attr.name == 'jpegPhoto' ||
|
||||
attr.name == '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) && !illegal"
|
||||
@click="addRow"
|
||||
class="add-btn control"
|
||||
title="Add row"
|
||||
>⊕</span
|
||||
>
|
||||
<span v-else class="mr-5"></span>
|
||||
|
||||
<span
|
||||
v-if="
|
||||
attr.name == 'jpegPhoto' ||
|
||||
attr.name == 'thumbnailPhoto'
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="val"
|
||||
:src="
|
||||
'data:image/' +
|
||||
(attr.name == '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 ml-1"
|
||||
@click="deleteBlob(index)"
|
||||
title="Remove photo"
|
||||
>⊖</span
|
||||
>
|
||||
</span>
|
||||
<span v-else-if="boolean">
|
||||
<span
|
||||
v-if="index == 0 && !values[0]"
|
||||
class="control text-lg"
|
||||
@click="updateValue(index, 'FALSE')"
|
||||
>⊕</span
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="pb-1 border-primary focus-within:border-b border-solid"
|
||||
>
|
||||
<toggle-button
|
||||
:id="attr + '-' + index"
|
||||
:value="values[index]"
|
||||
class="mt-2"
|
||||
@update:value="updateValue(index, $event)"
|
||||
/>
|
||||
<i
|
||||
class="fa fa-trash ml-2 relative -top-0.5 control"
|
||||
@click="updateValue(index, '')"
|
||||
></i>
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
v-else
|
||||
:value="values[index]"
|
||||
:id="attr + '-' + index"
|
||||
:type="type"
|
||||
autocomplete="off"
|
||||
class="w-[90%] glyph outline-none bg-back border-x-0 border-t-0 border-b border-solid border-front/20 focus:border-primary px-1"
|
||||
:class="{
|
||||
structural: isStructural(val),
|
||||
auto: defaultValue,
|
||||
illegal: (illegal && !empty) || duplicate(index),
|
||||
}"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:title="time ? dateString(val) : ''"
|
||||
@input="update"
|
||||
@focusin="query = ''"
|
||||
@keyup="search"
|
||||
@keyup.esc="query = ''"
|
||||
/>
|
||||
|
||||
<i
|
||||
v-if="attr.name == 'objectClass'"
|
||||
class="cursor-pointer fa fa-info-circle"
|
||||
@click="emit('show-oc', val)"
|
||||
></i>
|
||||
</div>
|
||||
<search-results
|
||||
silent
|
||||
v-if="completable && elementId"
|
||||
@select-dn="complete"
|
||||
:for="elementId"
|
||||
:query="query"
|
||||
label="dn"
|
||||
:shorten="baseDn"
|
||||
/>
|
||||
<attribute-search
|
||||
v-if="oid && elementId"
|
||||
@done="complete"
|
||||
:for="elementId"
|
||||
:query="query"
|
||||
/>
|
||||
<div v-if="hint" class="text-xs ml-6 opacity-70">{{ hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-3/4">
|
||||
<div v-for="(val, index) in values" :key="index">
|
||||
<span v-if="isStructural(val)" @click="emit('show-modal', 'add-object-class')" tabindex="-1"
|
||||
class="add-btn control font-bold" title="Add object class…">⊕</span>
|
||||
<span v-else-if="isAux(val)" @click="removeObjectClass(index)"
|
||||
class="remove-btn control" :title="'Remove ' + val">⊖</span>
|
||||
<span v-else-if="password" class="fa fa-question-circle control"
|
||||
@click="emit('show-modal', 'change-password')" tabindex="-1" title="change password"></span>
|
||||
<span v-else-if="attr.name == 'jpegPhoto' || attr.name == '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) && !illegal" @click="addRow"
|
||||
class="add-btn control" title="Add row">⊕</span>
|
||||
<span v-else class="mr-5"></span>
|
||||
|
||||
<span v-if="attr.name == 'jpegPhoto' || attr.name == 'thumbnailPhoto'">
|
||||
<img v-if="val" :src="'data:image/' + ((attr.name == '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 ml-1"
|
||||
@click="deleteBlob(index)" title="Remove photo">⊖</span>
|
||||
</span>
|
||||
<span v-else-if="boolean">
|
||||
<span v-if="index == 0 && !values[0]" class="control text-lg" @click="updateValue(index, 'FALSE')">⊕</span>
|
||||
<span v-else class="pb-1 border-primary focus-within:border-b border-solid">
|
||||
<toggle-button :id="attr + '-' + index" :value="values[index]" class="mt-2"
|
||||
@update:value="updateValue(index, $event)" />
|
||||
<i class="fa fa-trash ml-2 relative -top-0.5 control" @click="updateValue(index, '')"></i>
|
||||
</span>
|
||||
</span>
|
||||
<input v-else :value="values[index]" :id="attr + '-' + index" :type="type" autocomplete="off"
|
||||
class="w-[90%] glyph outline-none bg-back border-x-0 border-t-0 border-b border-solid border-front/20 focus:border-primary px-1"
|
||||
:class="{ structural: isStructural(val), auto: defaultValue, illegal: (illegal && !empty) || duplicate(index) }"
|
||||
:placeholder="placeholder" :disabled="disabled" :title="time ? dateString(val) : ''"
|
||||
@input="update" @focusin="query = ''" @keyup="search" @keyup.esc="query = ''" />
|
||||
|
||||
<i v-if="attr.name == 'objectClass'" class="cursor-pointer fa fa-info-circle"
|
||||
@click="emit('show-oc', val)"></i>
|
||||
</div>
|
||||
<search-results silent v-if="completable && elementId" @select-dn="complete"
|
||||
:for="elementId" :query="query" label="dn" :shorten="baseDn" />
|
||||
<attribute-search v-if="oid && elementId" @done="complete" :for="elementId" :query="query" />
|
||||
<div v-if="hint" class="text-xs ml-6 opacity-70">{{ hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Attribute, generalizedTime } from '../schema/schema';
|
||||
import { computed, inject, onMounted, onUpdated, ref, watch } from 'vue';
|
||||
import AttributeSearch from './AttributeSearch.vue';
|
||||
import type { Provided } from '../Provided';
|
||||
import SearchResults from '../SearchResults.vue';
|
||||
import ToggleButton from '../ui/ToggleButton.vue';
|
||||
import { Attribute, generalizedTime } from "../schema/schema";
|
||||
import { computed, inject, onMounted, onUpdated, ref, watch } from "vue";
|
||||
import AttributeSearch from "./AttributeSearch.vue";
|
||||
import type { Provided } from "../Provided";
|
||||
import SearchResults from "../SearchResults.vue";
|
||||
import ToggleButton from "../ui/ToggleButton.vue";
|
||||
|
||||
function unique(element: unknown, index: number, array: Array<unknown>): boolean {
|
||||
return element == '' || array.indexOf(element) == index;
|
||||
}
|
||||
function unique(
|
||||
element: unknown,
|
||||
index: number,
|
||||
array: Array<unknown>
|
||||
): boolean {
|
||||
return element == "" || array.indexOf(element) == index;
|
||||
}
|
||||
|
||||
const dateFormat: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric'
|
||||
},
|
||||
const dateFormat: Intl.DateTimeFormatOptions = {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
},
|
||||
syntaxes = {
|
||||
boolean: "1.3.6.1.4.1.1466.115.121.1.7",
|
||||
distinguishedName: "1.3.6.1.4.1.1466.115.121.1.12",
|
||||
generalizedTime: "1.3.6.1.4.1.1466.115.121.1.24",
|
||||
integer: "1.3.6.1.4.1.1466.115.121.1.27",
|
||||
oid: "1.3.6.1.4.1.1466.115.121.1.38",
|
||||
telephoneNumber: "1.3.6.1.4.1.1466.115.121.1.50",
|
||||
},
|
||||
idRanges = ["uidNumber", "gidNumber"], // Numeric ID ranges
|
||||
props = defineProps({
|
||||
attr: { type: Attribute, required: true },
|
||||
baseDn: String,
|
||||
values: { type: Array<string>, required: true },
|
||||
meta: { type: Object, required: true },
|
||||
must: { type: Boolean, required: true },
|
||||
may: { type: Boolean, required: true },
|
||||
changed: { type: Boolean, required: true },
|
||||
}),
|
||||
app = inject<Provided>("app"),
|
||||
valid = ref(true),
|
||||
// Range auto-completion
|
||||
autoFilled = ref<string>(),
|
||||
hint = ref(""),
|
||||
// DN search
|
||||
query = ref(""),
|
||||
elementId = ref<string>(),
|
||||
boolean = computed(() => props.attr.syntax == syntaxes.boolean),
|
||||
completable = computed(
|
||||
() => props.attr.syntax == syntaxes.distinguishedName
|
||||
),
|
||||
defaultValue = computed(
|
||||
() => props.values.length == 1 && props.values[0] == autoFilled.value
|
||||
),
|
||||
empty = computed(() => props.values.every((value) => !value.trim())),
|
||||
illegal = computed(() => !props.must && !props.may),
|
||||
isRdn = computed(() => props.attr.name == props.meta.dn.split("=")[0]),
|
||||
oid = computed(() => props.attr.syntax == syntaxes.oid),
|
||||
missing = computed(() => empty.value && props.must),
|
||||
password = computed(() => props.attr.name == "userPassword"),
|
||||
time = computed(() => props.attr.syntax == syntaxes.generalizedTime),
|
||||
binary = computed<boolean>(() =>
|
||||
password.value
|
||||
? false // Corner case with octetStringMatch
|
||||
: props.meta.binary.includes(props.attr.name)
|
||||
),
|
||||
disabled = computed(
|
||||
() =>
|
||||
isRdn.value ||
|
||||
props.attr.name == "objectClass" ||
|
||||
(illegal.value && empty.value) ||
|
||||
(!props.meta.isNew && (password.value || binary.value))
|
||||
),
|
||||
placeholder = computed(() => {
|
||||
let symbol = "";
|
||||
if (completable.value) symbol = " \uf002 "; // fa-search
|
||||
if (missing.value) symbol = " \uf071 "; // fa-warning
|
||||
if (empty.value) symbol = " \uf1f8 "; // fa-trash
|
||||
return symbol;
|
||||
}),
|
||||
shown = computed(
|
||||
() =>
|
||||
props.attr.name == "jpegPhoto" ||
|
||||
props.attr.name == "thumbnailPhoto" ||
|
||||
(!props.attr.no_user_mod && !binary.value)
|
||||
),
|
||||
type = computed(() => {
|
||||
// Guess the <input> type for an attribute
|
||||
if (password.value) return "password";
|
||||
if (props.attr.syntax == syntaxes.telephoneNumber) return "tel";
|
||||
return props.attr.syntax == syntaxes.integer ? "number" : "text";
|
||||
}),
|
||||
emit = defineEmits([
|
||||
"reload-form",
|
||||
"show-attr",
|
||||
"show-modal",
|
||||
"show-oc",
|
||||
"update",
|
||||
"valid",
|
||||
]);
|
||||
|
||||
syntaxes = {
|
||||
boolean: '1.3.6.1.4.1.1466.115.121.1.7',
|
||||
distinguishedName: '1.3.6.1.4.1.1466.115.121.1.12',
|
||||
generalizedTime: '1.3.6.1.4.1.1466.115.121.1.24',
|
||||
integer: '1.3.6.1.4.1.1466.115.121.1.27',
|
||||
oid: '1.3.6.1.4.1.1466.115.121.1.38',
|
||||
telephoneNumber: '1.3.6.1.4.1.1466.115.121.1.50',
|
||||
},
|
||||
|
||||
idRanges = ['uidNumber', 'gidNumber'], // Numeric ID ranges
|
||||
watch(valid, (ok) => emit("valid", ok));
|
||||
|
||||
props = defineProps({
|
||||
attr: { type: Attribute, required: true },
|
||||
baseDn: String,
|
||||
values: { type: Array<string>, required: true },
|
||||
meta: { type: Object, required: true },
|
||||
must: { type: Boolean, required: true },
|
||||
may: { type: Boolean, required: true },
|
||||
changed: { type: Boolean, required: true },
|
||||
}),
|
||||
|
||||
app = inject<Provided>('app'),
|
||||
|
||||
valid = ref(true),
|
||||
|
||||
// Range auto-completion
|
||||
autoFilled = ref<string>(),
|
||||
hint = ref(''),
|
||||
|
||||
// DN search
|
||||
query = ref(''),
|
||||
elementId = ref<string>(),
|
||||
|
||||
boolean = computed(() => props.attr.syntax == syntaxes.boolean),
|
||||
completable = computed(() => props.attr.syntax == syntaxes.distinguishedName),
|
||||
defaultValue = computed(() => props.values.length == 1 && props.values[0] == autoFilled.value),
|
||||
empty = computed(() => props.values.every(value => !value.trim())),
|
||||
illegal = computed(() => !props.must && !props.may),
|
||||
isRdn = computed(() => props.attr.name == props.meta.dn.split('=')[0]),
|
||||
oid = computed(() => props.attr.syntax == syntaxes.oid),
|
||||
missing = computed(() => empty.value && props.must),
|
||||
password = computed(() => props.attr.name == 'userPassword'),
|
||||
time = computed(() => props.attr.syntax == syntaxes.generalizedTime),
|
||||
|
||||
binary = computed<boolean>(() =>
|
||||
password.value ? false // Corner case with octetStringMatch
|
||||
: props.meta.binary.includes(props.attr.name)),
|
||||
|
||||
disabled = computed(() => isRdn.value
|
||||
|| props.attr.name == 'objectClass'
|
||||
|| (illegal.value && empty.value)
|
||||
|| (!props.meta.isNew && (password.value || binary.value))),
|
||||
|
||||
placeholder = computed(() => {
|
||||
let symbol = '';
|
||||
if (completable.value) symbol = ' \uf002 '; // fa-search
|
||||
if (missing.value) symbol = ' \uf071 '; // fa-warning
|
||||
if (empty.value) symbol = ' \uf1f8 '; // fa-trash
|
||||
return symbol;
|
||||
}),
|
||||
|
||||
shown = computed(() =>
|
||||
props.attr.name == 'jpegPhoto'
|
||||
|| props.attr.name == 'thumbnailPhoto'
|
||||
|| (!props.attr.no_user_mod && !binary.value)),
|
||||
|
||||
type = computed(() => {
|
||||
// Guess the <input> type for an attribute
|
||||
if (password.value) return 'password';
|
||||
if (props.attr.syntax == syntaxes.telephoneNumber) return 'tel';
|
||||
return props.attr.syntax == syntaxes.integer ? 'number' : 'text';
|
||||
}),
|
||||
|
||||
emit = defineEmits(['reload-form', 'show-attr', 'show-modal', 'show-oc', 'update', 'valid']);
|
||||
|
||||
watch(valid, (ok) => emit('valid', ok));
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(async () => {
|
||||
// Auto-fill ranges
|
||||
if (disabled.value
|
||||
|| !idRanges.includes(props.attr.name!)
|
||||
|| props.values.length != 1
|
||||
|| props.values[0]) return;
|
||||
if (
|
||||
disabled.value ||
|
||||
!idRanges.includes(props.attr.name!) ||
|
||||
props.values.length != 1 ||
|
||||
props.values[0]
|
||||
)
|
||||
return;
|
||||
|
||||
const response = await fetch('api/range/' + props.attr.name);
|
||||
const response = await fetch("api/range/" + props.attr.name);
|
||||
if (!response.ok) return;
|
||||
|
||||
const range = await response.json() as {
|
||||
min: number;
|
||||
max: number;
|
||||
next: number;
|
||||
|
||||
const range = (await response.json()) as {
|
||||
min: number;
|
||||
max: number;
|
||||
next: number;
|
||||
};
|
||||
hint.value = range.min == range.max
|
||||
? '> ' + range.min
|
||||
: '\u2209 (' + range.min + " - " + range.max + ')';
|
||||
autoFilled.value = '' + range.next;
|
||||
emit('update', props.attr.name, [autoFilled.value], 0);
|
||||
hint.value =
|
||||
range.min == range.max
|
||||
? "> " + range.min
|
||||
: "\u2209 (" + range.min + " - " + range.max + ")";
|
||||
autoFilled.value = "" + range.next;
|
||||
emit("update", props.attr.name, [autoFilled.value], 0);
|
||||
validate();
|
||||
});
|
||||
});
|
||||
|
||||
onUpdated(validate);
|
||||
onUpdated(validate);
|
||||
|
||||
function validate() {
|
||||
valid.value = !missing.value
|
||||
&& (!illegal.value || empty.value)
|
||||
&& props.values.every(unique);
|
||||
}
|
||||
function validate() {
|
||||
valid.value =
|
||||
!missing.value &&
|
||||
(!illegal.value || empty.value) &&
|
||||
props.values.every(unique);
|
||||
}
|
||||
|
||||
function update(evt: Event) {
|
||||
function update(evt: Event) {
|
||||
const target = evt.target as HTMLInputElement;
|
||||
const value = target.value,
|
||||
index = +target.id.split('-').slice(-1).pop()!;
|
||||
updateValue(index, value);
|
||||
}
|
||||
index = +target.id.split("-").slice(-1).pop()!;
|
||||
updateValue(index, value);
|
||||
}
|
||||
|
||||
function updateValue(index: number, value: string) {
|
||||
function updateValue(index: number, value: string) {
|
||||
const values = props.values.slice();
|
||||
values[index] = value;
|
||||
emit('update', props.attr.name, values);
|
||||
}
|
||||
emit("update", props.attr.name, values);
|
||||
}
|
||||
|
||||
// Add an empty row in the entry form
|
||||
function addRow() {
|
||||
// Add an empty row in the entry form
|
||||
function addRow() {
|
||||
const values = props.values.slice();
|
||||
if (!values.includes('')) values.push('');
|
||||
emit('update', props.attr.name, values, values.length - 1);
|
||||
}
|
||||
if (!values.includes("")) values.push("");
|
||||
emit("update", props.attr.name, values, values.length - 1);
|
||||
}
|
||||
|
||||
// Remove a row from the entry form
|
||||
function removeObjectClass(index: number) {
|
||||
const values = props.values.slice(0, index).concat(props.values.slice(index + 1));
|
||||
emit('update', 'objectClass', values);
|
||||
}
|
||||
// Remove a row from the entry form
|
||||
function removeObjectClass(index: number) {
|
||||
const values = props.values
|
||||
.slice(0, index)
|
||||
.concat(props.values.slice(index + 1));
|
||||
emit("update", "objectClass", values);
|
||||
}
|
||||
|
||||
// human-readable dates
|
||||
function dateString(dt: string) {
|
||||
// human-readable dates
|
||||
function dateString(dt: string) {
|
||||
return generalizedTime(dt).toLocaleString(undefined, dateFormat);
|
||||
}
|
||||
}
|
||||
|
||||
// Is the given value a structural object class?
|
||||
function isStructural(val: string) {
|
||||
return props.attr.name == 'objectClass' && app?.schema?.oc(val)?.structural;
|
||||
}
|
||||
// Is the given value a structural object class?
|
||||
function isStructural(val: string) {
|
||||
return props.attr.name == "objectClass" && app?.schema?.oc(val)?.structural;
|
||||
}
|
||||
|
||||
// Is the given value an auxillary object class?
|
||||
function isAux(val: string) {
|
||||
// Is the given value an auxillary object class?
|
||||
function isAux(val: string) {
|
||||
const oc = app?.schema?.oc(val);
|
||||
return props.attr.name == 'objectClass' && oc && !oc.structural;
|
||||
}
|
||||
return props.attr.name == "objectClass" && oc && !oc.structural;
|
||||
}
|
||||
|
||||
function duplicate(index: number) {
|
||||
function duplicate(index: number) {
|
||||
return !unique(props.values[index], index, props.values);
|
||||
}
|
||||
}
|
||||
|
||||
function multiple(index: number) {
|
||||
return index == 0
|
||||
&& !props.attr.single_value
|
||||
&& !disabled.value
|
||||
&& !props.values.includes('');
|
||||
}
|
||||
function multiple(index: number) {
|
||||
return (
|
||||
index == 0 &&
|
||||
!props.attr.single_value &&
|
||||
!disabled.value &&
|
||||
!props.values.includes("")
|
||||
);
|
||||
}
|
||||
|
||||
// auto-complete form values
|
||||
function search(evt: Event) {
|
||||
// auto-complete form values
|
||||
function search(evt: Event) {
|
||||
const target = evt.target as HTMLInputElement;
|
||||
elementId.value = target.id;
|
||||
const q = target.value;
|
||||
query.value = q.length >= 2 && !q.includes(',') ? q : '';
|
||||
}
|
||||
query.value = q.length >= 2 && !q.includes(",") ? q : "";
|
||||
}
|
||||
|
||||
// use an auto-completion choice
|
||||
function complete(dn: string) {
|
||||
const index = +elementId.value!.split('-').slice(-1).pop()!;
|
||||
// use an auto-completion choice
|
||||
function complete(dn: string) {
|
||||
const index = +elementId.value!.split("-").slice(-1).pop()!;
|
||||
const values = props.values.slice();
|
||||
values[index] = dn;
|
||||
query.value = '';
|
||||
emit('update', props.attr.name, values);
|
||||
}
|
||||
|
||||
// remove an image
|
||||
async function deleteBlob(index: number) {
|
||||
const response = await fetch('api/blob/' + props.attr.name + '/' + index + '/' + props.meta.dn, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) emit('reload-form', props.meta.dn, [props.attr.name]);
|
||||
}
|
||||
query.value = "";
|
||||
emit("update", props.attr.name, values);
|
||||
}
|
||||
|
||||
// remove an image
|
||||
async function deleteBlob(index: number) {
|
||||
const response = await fetch(
|
||||
"api/blob/" + props.attr.name + "/" + index + "/" + props.meta.dn,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
if (response.ok) emit("reload-form", props.meta.dn, [props.attr.name]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
div.optional span.oc {
|
||||
div.optional span.oc {
|
||||
@apply text-front/70;
|
||||
}
|
||||
}
|
||||
|
||||
div.illegal, input.illegal {
|
||||
div.illegal,
|
||||
input.illegal {
|
||||
@apply line-through text-danger;
|
||||
}
|
||||
}
|
||||
|
||||
div.rdn span.oc, input.structural {
|
||||
div.rdn span.oc,
|
||||
input.structural {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn, .remove-btn, .fa-info-circle, .fa-question-circle {
|
||||
.add-btn,
|
||||
.remove-btn,
|
||||
.fa-info-circle,
|
||||
.fa-question-circle {
|
||||
@apply opacity-40 hover:opacity-70 text-base;
|
||||
}
|
||||
|
||||
input.disabled, input:disabled {
|
||||
}
|
||||
|
||||
input.disabled,
|
||||
input:disabled {
|
||||
@apply border-b-0;
|
||||
}
|
||||
}
|
||||
|
||||
input.auto {
|
||||
input.auto {
|
||||
@apply text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
div.rdn span.oc::after {
|
||||
content: ' (rdn)';
|
||||
div.rdn span.oc::after {
|
||||
content: " (rdn)";
|
||||
font-weight: 200;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,47 +1,58 @@
|
||||
<template>
|
||||
<popover :open="show" @update:open="results = []">
|
||||
<li v-for="item in results" :key="item.oid" @click="done(item.name!)"
|
||||
:title="item.oid" role="menuitem">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</popover>
|
||||
<popover :open="show" @update:open="results = []">
|
||||
<li
|
||||
v-for="item in results"
|
||||
:key="item.oid"
|
||||
@click="done(item.name!)"
|
||||
:title="item.oid"
|
||||
role="menuitem"
|
||||
>
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Attribute } from '../schema/schema';
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue';
|
||||
import Popover from '../ui/Popover.vue';
|
||||
import type { Provided } from '../Provided';
|
||||
import type { Attribute } from "../schema/schema";
|
||||
import { computed, inject, nextTick, ref, watch } from "vue";
|
||||
import Popover from "../ui/Popover.vue";
|
||||
import type { Provided } from "../Provided";
|
||||
|
||||
const props = defineProps({
|
||||
query: { type: String, default: ''},
|
||||
for: { type: String, default: ''},
|
||||
const props = defineProps({
|
||||
query: { type: String, default: "" },
|
||||
for: { type: String, default: "" },
|
||||
}),
|
||||
app = inject<Provided>('app'),
|
||||
app = inject<Provided>("app"),
|
||||
results = ref<Attribute[]>([]),
|
||||
show = computed(() => props.query.trim() != ''
|
||||
&& results.value
|
||||
&& results.value.length > 0
|
||||
&& !(results.value.length == 1 && props.query == results.value[0].name)),
|
||||
emit = defineEmits(['done']);
|
||||
show = computed(
|
||||
() =>
|
||||
props.query.trim() != "" &&
|
||||
results.value &&
|
||||
results.value.length > 0 &&
|
||||
!(results.value.length == 1 && props.query == results.value[0].name)
|
||||
),
|
||||
emit = defineEmits(["done"]);
|
||||
|
||||
watch(() => props.query,
|
||||
watch(
|
||||
() => props.query,
|
||||
(q) => {
|
||||
if (!q) return;
|
||||
results.value = app?.schema?.search(q) || [];
|
||||
results.value.sort((a: Attribute, b: Attribute) =>
|
||||
a.name!.toLowerCase().localeCompare(b.name!.toLowerCase()));
|
||||
});
|
||||
if (!q) return;
|
||||
results.value = app?.schema?.search(q) || [];
|
||||
results.value.sort((a: Attribute, b: Attribute) =>
|
||||
a.name!.toLowerCase().localeCompare(b.name!.toLowerCase())
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// use an auto-completion choice
|
||||
function done(value: string) {
|
||||
emit('done', value);
|
||||
// use an auto-completion choice
|
||||
function done(value: string) {
|
||||
emit("done", value);
|
||||
results.value = [];
|
||||
|
||||
nextTick(()=> {
|
||||
// Return focus to search input
|
||||
const el = document.getElementById(props.for);
|
||||
if (el) el.focus();
|
||||
nextTick(() => {
|
||||
// Return focus to search input
|
||||
const el = document.getElementById(props.for);
|
||||
if (el) el.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,55 +1,65 @@
|
||||
<template>
|
||||
<modal title="Copy entry" :open="modal == 'copy-entry'" :return-to="returnTo"
|
||||
@show="init" @shown="newdn?.focus()"
|
||||
@ok="onOk" @cancel="emit('update:modal')">
|
||||
|
||||
<div>
|
||||
<div class="text-danger text-xs mb-1" v-if="error">{{ error }}</div>
|
||||
<input ref="newdn" v-model="dn" placeholder="New DN" @keyup.enter="onOk" />
|
||||
</div>
|
||||
</modal>
|
||||
<modal
|
||||
title="Copy entry"
|
||||
:open="modal == 'copy-entry'"
|
||||
:return-to="returnTo"
|
||||
@show="init"
|
||||
@shown="newdn?.focus()"
|
||||
@ok="onOk"
|
||||
@cancel="emit('update:modal')"
|
||||
>
|
||||
<div>
|
||||
<div class="text-danger text-xs mb-1" v-if="error">{{ error }}</div>
|
||||
<input
|
||||
ref="newdn"
|
||||
v-model="dn"
|
||||
placeholder="New DN"
|
||||
@keyup.enter="onOk"
|
||||
/>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import { ref } from "vue";
|
||||
import Modal from "../ui/Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
}),
|
||||
dn = ref(''),
|
||||
error = ref(''),
|
||||
dn = ref(""),
|
||||
error = ref(""),
|
||||
newdn = ref<HTMLInputElement | null>(null),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
emit = defineEmits(["ok", "update:modal"]);
|
||||
|
||||
function init() {
|
||||
error.value = '';
|
||||
function init() {
|
||||
error.value = "";
|
||||
dn.value = props.entry.meta.dn;
|
||||
}
|
||||
}
|
||||
|
||||
// Load copied entry into the editor
|
||||
function onOk() {
|
||||
if (!dn.value || dn.value== props.entry.meta.dn) {
|
||||
error.value = 'This DN already exists';
|
||||
return;
|
||||
// Load copied entry into the editor
|
||||
function onOk() {
|
||||
if (!dn.value || dn.value == props.entry.meta.dn) {
|
||||
error.value = "This DN already exists";
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = dn.value.split(','),
|
||||
rdnpart = parts[0].split('='),
|
||||
rdn = rdnpart[0];
|
||||
|
||||
const parts = dn.value.split(","),
|
||||
rdnpart = parts[0].split("="),
|
||||
rdn = rdnpart[0];
|
||||
|
||||
if (rdnpart.length != 2) {
|
||||
error.value = 'Invalid RDN: ' + parts[0];
|
||||
return;
|
||||
error.value = "Invalid RDN: " + parts[0];
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:modal');
|
||||
emit("update:modal");
|
||||
const entry = JSON.parse(JSON.stringify(props.entry));
|
||||
entry.attrs[rdn] = [rdnpart[1]];
|
||||
entry.meta.dn = dn.value;
|
||||
entry.meta.isNew = true;
|
||||
emit('ok', entry);
|
||||
}
|
||||
emit("ok", entry);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,52 +1,61 @@
|
||||
<template>
|
||||
<modal title="Are you sure?" :open="modal == 'delete-entry'" :return-to="returnTo"
|
||||
cancel-classes="bg-primary/80" ok-classes="bg-danger/80"
|
||||
@show="init" @shown="onShown" @ok="onOk" @cancel="emit('update:modal')">
|
||||
<modal
|
||||
title="Are you sure?"
|
||||
:open="modal == 'delete-entry'"
|
||||
:return-to="returnTo"
|
||||
cancel-classes="bg-primary/80"
|
||||
ok-classes="bg-danger/80"
|
||||
@show="init"
|
||||
@shown="onShown"
|
||||
@ok="onOk"
|
||||
@cancel="emit('update:modal')"
|
||||
>
|
||||
<p class="strong">This action is irreversible.</p>
|
||||
|
||||
<p class="strong">This action is irreversible.</p>
|
||||
<div v-if="subtree.length">
|
||||
<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="ml-6" :key="i"></span>
|
||||
<node-label :oc="node.structuralObjectClass">
|
||||
{{ node.dn.split(",")[0] }}
|
||||
</node-label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="subtree.length">
|
||||
<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="ml-6" :key="i"></span>
|
||||
<node-label :oc="node.structuralObjectClass">
|
||||
{{ node.dn.split(',')[0] }}
|
||||
</node-label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #modal-ok>
|
||||
<i class="fa fa-trash-o fa-lg"></i> Delete
|
||||
</template>
|
||||
</modal>
|
||||
<template #modal-ok>
|
||||
<i class="fa fa-trash-o fa-lg"></i> Delete
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import NodeLabel from '../NodeLabel.vue';
|
||||
import type { TreeNode } from '../TreeNode';
|
||||
import { ref } from "vue";
|
||||
import Modal from "../ui/Modal.vue";
|
||||
import NodeLabel from "../NodeLabel.vue";
|
||||
import type { TreeNode } from "../TreeNode";
|
||||
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
}),
|
||||
subtree = ref<TreeNode[]>([]),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
emit = defineEmits(["ok", "update:modal"]);
|
||||
|
||||
// List subordinate elements to be deleted
|
||||
async function init() {
|
||||
const response = await fetch('api/subtree/' + props.dn)
|
||||
subtree.value = await response.json() as TreeNode[];
|
||||
}
|
||||
// List subordinate elements to be deleted
|
||||
async function init() {
|
||||
const response = await fetch("api/subtree/" + props.dn);
|
||||
subtree.value = (await response.json()) as TreeNode[];
|
||||
}
|
||||
|
||||
function onShown() {
|
||||
document.getElementById('ui-modal-ok')?.focus();
|
||||
}
|
||||
function onShown() {
|
||||
document.getElementById("ui-modal-ok")?.focus();
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
emit('update:modal');
|
||||
emit('ok', props.dn);
|
||||
}
|
||||
function onOk() {
|
||||
emit("update:modal");
|
||||
emit("ok", props.dn);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,37 +1,43 @@
|
||||
<template>
|
||||
<modal title="Are you sure?" :open="modal == 'discard-entry'" :return-to="returnTo"
|
||||
cancel-classes="bg-primary/80" ok-classes="bg-danger/80"
|
||||
@show="next = dn;" @shown="onShown" @ok="onOk" @cancel="emit('update:modal')">
|
||||
<modal
|
||||
title="Are you sure?"
|
||||
:open="modal == 'discard-entry'"
|
||||
:return-to="returnTo"
|
||||
cancel-classes="bg-primary/80"
|
||||
ok-classes="bg-danger/80"
|
||||
@show="next = dn"
|
||||
@shown="onShown"
|
||||
@ok="onOk"
|
||||
@cancel="emit('update:modal')"
|
||||
>
|
||||
<p class="strong">All changes will be irreversibly lost.</p>
|
||||
|
||||
<p class="strong">All changes will be irreversibly lost.</p>
|
||||
|
||||
<template #modal-ok>
|
||||
<i class="fa fa-trash-o fa-lg"></i> Discard
|
||||
</template>
|
||||
</modal>
|
||||
<template #modal-ok>
|
||||
<i class="fa fa-trash-o fa-lg"></i> Discard
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import { ref } from "vue";
|
||||
import Modal from "../ui/Modal.vue";
|
||||
|
||||
defineProps({
|
||||
dn: String,
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
});
|
||||
defineProps({
|
||||
dn: String,
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
});
|
||||
|
||||
const
|
||||
next = ref<string>(),
|
||||
emit = defineEmits(['ok', 'shown', 'update:modal']);
|
||||
const next = ref<string>(),
|
||||
emit = defineEmits(["ok", "shown", "update:modal"]);
|
||||
|
||||
function onShown() {
|
||||
document.getElementById('ui-modal-ok')?.focus();
|
||||
emit('shown');
|
||||
}
|
||||
function onShown() {
|
||||
document.getElementById("ui-modal-ok")?.focus();
|
||||
emit("shown");
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
emit('update:modal');
|
||||
emit('ok', next.value);
|
||||
}
|
||||
function onOk() {
|
||||
emit("update:modal");
|
||||
emit("ok", next.value);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,340 +1,452 @@
|
||||
<template>
|
||||
<div v-if="entry" class="rounded border border-front/20 mb-3 mx-4 flex-auto">
|
||||
<div
|
||||
v-if="entry"
|
||||
class="rounded border border-front/20 mb-3 mx-4 flex-auto"
|
||||
>
|
||||
<!-- Modals for navigation menu -->
|
||||
<new-entry-dialog
|
||||
v-model:modal="modal"
|
||||
:dn="entry.meta.dn"
|
||||
:return-to="focused"
|
||||
@ok="newEntry"
|
||||
/>
|
||||
<copy-entry-dialog
|
||||
v-model:modal="modal"
|
||||
:entry="entry"
|
||||
:return-to="focused"
|
||||
@ok="newEntry"
|
||||
/>
|
||||
<rename-entry-dialog
|
||||
v-model:modal="modal"
|
||||
:entry="entry"
|
||||
:return-to="focused"
|
||||
@ok="renameEntry"
|
||||
/>
|
||||
<delete-entry-dialog
|
||||
v-model:modal="modal"
|
||||
:dn="entry.meta.dn"
|
||||
:return-to="focused"
|
||||
@ok="deleteEntry"
|
||||
/>
|
||||
<discard-entry-dialog
|
||||
v-model:modal="modal"
|
||||
:dn="props.activeDn"
|
||||
:return-to="focused"
|
||||
@ok="discardEntry"
|
||||
@shown="emit('update:activeDn')"
|
||||
/>
|
||||
|
||||
<!-- Modals for navigation menu -->
|
||||
<new-entry-dialog v-model:modal="modal" :dn="entry.meta.dn" :return-to="focused" @ok="newEntry" />
|
||||
<copy-entry-dialog v-model:modal="modal" :entry="entry" :return-to="focused" @ok="newEntry" />
|
||||
<rename-entry-dialog v-model:modal="modal" :entry="entry" :return-to="focused" @ok="renameEntry" />
|
||||
<delete-entry-dialog v-model:modal="modal" :dn="entry.meta.dn" :return-to="focused" @ok="deleteEntry" />
|
||||
<discard-entry-dialog v-model:modal="modal" :dn="props.activeDn" :return-to="focused"
|
||||
@ok="discardEntry" @shown="emit('update:activeDn')" />
|
||||
<!-- Modals for main editing area -->
|
||||
<password-change-dialog
|
||||
v-model:modal="modal"
|
||||
:entry="entry"
|
||||
:return-to="focused"
|
||||
:user="user"
|
||||
@ok="changePassword"
|
||||
/>
|
||||
<add-photo-dialog
|
||||
v-model:modal="modal"
|
||||
attr="jpegPhoto"
|
||||
:dn="entry.meta.dn"
|
||||
:return-to="focused"
|
||||
@ok="load"
|
||||
/>
|
||||
<add-photo-dialog
|
||||
v-model:modal="modal"
|
||||
attr="thumbnailPhoto"
|
||||
:dn="entry.meta.dn"
|
||||
:return-to="focused"
|
||||
@ok="load"
|
||||
/>
|
||||
<add-object-class-dialog
|
||||
v-model:modal="modal"
|
||||
:entry="entry"
|
||||
:return-to="focused"
|
||||
@ok="addObjectClass"
|
||||
/>
|
||||
|
||||
<!-- Modals for main editing area -->
|
||||
<password-change-dialog v-model:modal="modal"
|
||||
:entry="entry" :return-to="focused" :user="user"
|
||||
@ok="changePassword" />
|
||||
<add-photo-dialog v-model:modal="modal" attr="jpegPhoto"
|
||||
:dn="entry.meta.dn" :return-to="focused"
|
||||
@ok="load" />
|
||||
<add-photo-dialog v-model:modal="modal" attr="thumbnailPhoto"
|
||||
:dn="entry.meta.dn" :return-to="focused" @ok="load" />
|
||||
<add-object-class-dialog v-model:modal="modal"
|
||||
:entry="entry" :return-to="focused" @ok="addObjectClass" />
|
||||
|
||||
<!-- Modals for footer -->
|
||||
<add-attribute-dialog v-model:modal="modal"
|
||||
:entry="entry" :attributes="attributes('may')" :return-to="focused"
|
||||
@ok="addAttribute" @show-modal="modal = $event;" />
|
||||
|
||||
<nav class="flex justify-between mb-4 border-b border-front/20 bg-primary/70">
|
||||
<div v-if="entry.meta.isNew" class="py-2 ml-3">
|
||||
<node-label :dn="entry.meta.dn" :oc="structural" />
|
||||
</div>
|
||||
<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>
|
||||
<!-- Modals for footer -->
|
||||
<add-attribute-dialog
|
||||
v-model:modal="modal"
|
||||
:entry="entry"
|
||||
:attributes="attributes('may')"
|
||||
:return-to="focused"
|
||||
@ok="addAttribute"
|
||||
@show-modal="modal = $event"
|
||||
/>
|
||||
|
||||
<div v-if="entry.meta.isNew" class="control text-2xl mr-2"
|
||||
@click="modal = 'discard-entry';" title="close">⊗</div>
|
||||
<div v-else class="control text-xl mr-2" title="close"
|
||||
@click="emit('update:activeDn')">⊗</div>
|
||||
</nav>
|
||||
|
||||
<form id="entry" class="space-y-4 my-4" @submit.prevent="save"
|
||||
@reset="load(entry!.meta.dn, undefined, undefined)" @focusin="onFocus">
|
||||
<attribute-row v-for="key in keys" :key="key" :base-dn="props.baseDn"
|
||||
:attr="app?.schema?.attr(key)!" :meta="entry.meta" :values="entry.attrs[key]"
|
||||
:changed="hasChanged(key)"
|
||||
:may="attributes('may').includes(key)" :must="attributes('must').includes(key)"
|
||||
@update="updateRow"
|
||||
@reload-form="load"
|
||||
@valid="valid(key, $event)"
|
||||
@show-modal="modal = $event;"
|
||||
@show-attr="emit('show-attr', $event)"
|
||||
@show-oc="emit('show-oc', $event)" />
|
||||
<nav
|
||||
class="flex justify-between mb-4 border-b border-front/20 bg-primary/70"
|
||||
>
|
||||
<div v-if="entry.meta.isNew" class="py-2 ml-3">
|
||||
<node-label :dn="entry.meta.dn" :oc="structural" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Footer with buttons -->
|
||||
<div class="flex ml-4 mt-2 space-x-4">
|
||||
<div class="w-1/4"></div>
|
||||
<div class="w-3/4 pl-4">
|
||||
<div class="w-[90%] space-x-3">
|
||||
<button type="submit" class="btn bg-primary/70" tabindex="0"
|
||||
accesskey="s" :disabled="invalid.length != 0">Submit</button>
|
||||
<button type="reset" v-if="!entry.meta.isNew" accesskey="r"
|
||||
tabindex="0" class="btn bg-secondary">Reset</button>
|
||||
<button class="btn float-right bg-secondary" accesskey="a" tabindex="0"
|
||||
v-if="!entry.meta.isNew" @click.prevent="modal = 'add-attribute';">
|
||||
Add attribute…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
v-if="entry.meta.isNew"
|
||||
class="control text-2xl mr-2"
|
||||
@click="modal = 'discard-entry'"
|
||||
title="close"
|
||||
>
|
||||
⊗
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="control text-xl mr-2"
|
||||
title="close"
|
||||
@click="emit('update:activeDn')"
|
||||
>
|
||||
⊗
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<form
|
||||
id="entry"
|
||||
class="space-y-4 my-4"
|
||||
@submit.prevent="save"
|
||||
@reset="load(entry!.meta.dn, undefined, undefined)"
|
||||
@focusin="onFocus"
|
||||
>
|
||||
<attribute-row
|
||||
v-for="key in keys"
|
||||
:key="key"
|
||||
:base-dn="props.baseDn"
|
||||
:attr="app?.schema?.attr(key)!"
|
||||
:meta="entry.meta"
|
||||
:values="entry.attrs[key]"
|
||||
:changed="hasChanged(key)"
|
||||
:may="attributes('may').includes(key)"
|
||||
:must="attributes('must').includes(key)"
|
||||
@update="updateRow"
|
||||
@reload-form="load"
|
||||
@valid="valid(key, $event)"
|
||||
@show-modal="modal = $event"
|
||||
@show-attr="emit('show-attr', $event)"
|
||||
@show-oc="emit('show-oc', $event)"
|
||||
/>
|
||||
|
||||
<!-- Footer with buttons -->
|
||||
<div class="flex ml-4 mt-2 space-x-4">
|
||||
<div class="w-1/4"></div>
|
||||
<div class="w-3/4 pl-4">
|
||||
<div class="w-[90%] space-x-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn bg-primary/70"
|
||||
tabindex="0"
|
||||
accesskey="s"
|
||||
:disabled="invalid.length != 0"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="reset"
|
||||
v-if="!entry.meta.isNew"
|
||||
accesskey="r"
|
||||
tabindex="0"
|
||||
class="btn bg-secondary"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
class="btn float-right bg-secondary"
|
||||
accesskey="a"
|
||||
tabindex="0"
|
||||
v-if="!entry.meta.isNew"
|
||||
@click.prevent="modal = 'add-attribute'"
|
||||
>
|
||||
Add attribute…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue';
|
||||
import AddAttributeDialog from './AddAttributeDialog.vue';
|
||||
import AddObjectClassDialog from './AddObjectClassDialog.vue';
|
||||
import AddPhotoDialog from './AddPhotoDialog.vue';
|
||||
import AttributeRow from './AttributeRow.vue';
|
||||
import CopyEntryDialog from './CopyEntryDialog.vue';
|
||||
import DeleteEntryDialog from './DeleteEntryDialog.vue';
|
||||
import DiscardEntryDialog from './DiscardEntryDialog.vue';
|
||||
import DropdownMenu from '../ui/DropdownMenu.vue';
|
||||
import type { Entry } from './Entry';
|
||||
import NewEntryDialog from './NewEntryDialog.vue';
|
||||
import NodeLabel from '../NodeLabel.vue';
|
||||
import PasswordChangeDialog from './PasswordChangeDialog.vue';
|
||||
import type { Provided } from '../Provided';
|
||||
import RenameEntryDialog from './RenameEntryDialog.vue';
|
||||
import { computed, inject, nextTick, ref, watch } from "vue";
|
||||
import AddAttributeDialog from "./AddAttributeDialog.vue";
|
||||
import AddObjectClassDialog from "./AddObjectClassDialog.vue";
|
||||
import AddPhotoDialog from "./AddPhotoDialog.vue";
|
||||
import AttributeRow from "./AttributeRow.vue";
|
||||
import CopyEntryDialog from "./CopyEntryDialog.vue";
|
||||
import DeleteEntryDialog from "./DeleteEntryDialog.vue";
|
||||
import DiscardEntryDialog from "./DiscardEntryDialog.vue";
|
||||
import DropdownMenu from "../ui/DropdownMenu.vue";
|
||||
import type { Entry } from "./Entry";
|
||||
import NewEntryDialog from "./NewEntryDialog.vue";
|
||||
import NodeLabel from "../NodeLabel.vue";
|
||||
import PasswordChangeDialog from "./PasswordChangeDialog.vue";
|
||||
import type { Provided } from "../Provided";
|
||||
import RenameEntryDialog from "./RenameEntryDialog.vue";
|
||||
|
||||
function unique(element: unknown, index: number, array: Array<unknown>): boolean {
|
||||
function unique(
|
||||
element: unknown,
|
||||
index: number,
|
||||
array: Array<unknown>
|
||||
): boolean {
|
||||
return array.indexOf(element) == index;
|
||||
}
|
||||
|
||||
const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'],
|
||||
}
|
||||
|
||||
const inputTags = ["BUTTON", "INPUT", "SELECT", "TEXTAREA"],
|
||||
props = defineProps({
|
||||
activeDn: String,
|
||||
baseDn: String,
|
||||
user: String,
|
||||
activeDn: String,
|
||||
baseDn: String,
|
||||
user: String,
|
||||
}),
|
||||
|
||||
app = inject<Provided>('app'),
|
||||
entry = ref<Entry>(), // entry in editor
|
||||
app = inject<Provided>("app"),
|
||||
entry = ref<Entry>(), // entry in editor
|
||||
focused = ref<string>(), // currently focused input
|
||||
invalid = ref<string[]>([]), // field IDs with validation errors
|
||||
modal = ref<string>(), // pop-up dialog
|
||||
|
||||
invalid = ref<string[]>([]), // field IDs with validation errors
|
||||
modal = ref<string>(), // pop-up dialog
|
||||
keys = computed(() => {
|
||||
const keys = Object.keys(entry.value?.attrs || {});
|
||||
keys.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
return keys;
|
||||
const keys = Object.keys(entry.value?.attrs || {});
|
||||
keys.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
return keys;
|
||||
}),
|
||||
|
||||
structural = computed(() => {
|
||||
const oc = entry.value?.attrs.objectClass
|
||||
.map(oc => app?.schema?.oc(oc as string))
|
||||
.filter(oc => oc && oc.structural)[0];
|
||||
return oc ? oc.name : '';
|
||||
const oc = entry.value?.attrs.objectClass
|
||||
.map((oc) => app?.schema?.oc(oc as string))
|
||||
.filter((oc) => oc && oc.structural)[0];
|
||||
return oc ? oc.name : "";
|
||||
}),
|
||||
emit = defineEmits(["update:activeDn", "show-attr", "show-oc"]);
|
||||
|
||||
emit = defineEmits(['update:activeDn', 'show-attr', 'show-oc']);
|
||||
watch(
|
||||
() => props.activeDn,
|
||||
(dn) => {
|
||||
if (!entry.value || dn != entry.value!.meta.dn)
|
||||
focused.value = undefined;
|
||||
|
||||
watch(() => props.activeDn, (dn) => {
|
||||
if (!entry.value || dn != entry.value!.meta.dn) focused.value = undefined;
|
||||
|
||||
if (dn && entry.value && entry.value!.meta.isNew) {
|
||||
modal.value = 'discard-entry';
|
||||
if (dn && entry.value && entry.value!.meta.isNew) {
|
||||
modal.value = "discard-entry";
|
||||
} else if (dn) load(dn, undefined, undefined);
|
||||
else if (entry.value && !entry.value!.meta.isNew)
|
||||
entry.value = undefined;
|
||||
}
|
||||
else if (dn) load(dn, undefined, undefined);
|
||||
else if (entry.value && !entry.value!.meta.isNew) entry.value = undefined;
|
||||
});
|
||||
);
|
||||
|
||||
function focus(focused: string | undefined) {
|
||||
function focus(focused: string | undefined) {
|
||||
nextTick(() => {
|
||||
const input = focused ? document.getElementById(focused)
|
||||
: document.querySelector('form#entry input:not([disabled]), form#entry button[type="button"]') as HTMLElement;
|
||||
|
||||
if (input) {
|
||||
// work around annoying focus jump in OS X Safari
|
||||
window.setTimeout(() => input.focus(), 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
const input = focused
|
||||
? document.getElementById(focused)
|
||||
: (document.querySelector(
|
||||
'form#entry input:not([disabled]), form#entry button[type="button"]'
|
||||
) as HTMLElement);
|
||||
|
||||
// Track focus changes
|
||||
function onFocus(evt: FocusEvent) {
|
||||
if (input) {
|
||||
// work around annoying focus jump in OS X Safari
|
||||
window.setTimeout(() => input.focus(), 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track focus changes
|
||||
function onFocus(evt: FocusEvent) {
|
||||
const el = evt.target as HTMLElement;
|
||||
if (el.id && inputTags.includes(el.tagName)) focused.value = el.id;
|
||||
}
|
||||
}
|
||||
|
||||
function newEntry(newEntry: Entry) {
|
||||
function newEntry(newEntry: Entry) {
|
||||
entry.value = newEntry;
|
||||
emit('update:activeDn');
|
||||
emit("update:activeDn");
|
||||
focus(addMandatoryRows());
|
||||
}
|
||||
}
|
||||
|
||||
function discardEntry(dn: string) {
|
||||
function discardEntry(dn: string) {
|
||||
entry.value = undefined;
|
||||
emit('update:activeDn', dn);
|
||||
}
|
||||
emit("update:activeDn", dn);
|
||||
}
|
||||
|
||||
function addAttribute(attr: string) {
|
||||
entry.value!.attrs[attr] = [''];
|
||||
focus(attr + '-0');
|
||||
}
|
||||
function addAttribute(attr: string) {
|
||||
entry.value!.attrs[attr] = [""];
|
||||
focus(attr + "-0");
|
||||
}
|
||||
|
||||
function addObjectClass(oc: string) {
|
||||
function addObjectClass(oc: string) {
|
||||
entry.value!.attrs.objectClass.push(oc);
|
||||
const aux = entry.value!.meta.aux.filter(oc => oc < oc);
|
||||
const aux = entry.value!.meta.aux.filter((oc) => oc < oc);
|
||||
entry.value!.meta.aux.splice(aux.length, 1);
|
||||
focus(addMandatoryRows() || focused.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a row from the entry form
|
||||
function removeObjectClass(newOcs: string[]) {
|
||||
// Remove a row from the entry form
|
||||
function removeObjectClass(newOcs: string[]) {
|
||||
const removedOc = entry.value!.attrs.objectClass.filter(
|
||||
oc => !newOcs.includes(oc))[0];
|
||||
(oc) => !newOcs.includes(oc)
|
||||
)[0];
|
||||
if (removedOc) {
|
||||
const aux = entry.value!.meta.aux.filter(oc => oc < removedOc);
|
||||
entry.value!.meta.aux.splice(aux.length, 0, removedOc);
|
||||
const aux = entry.value!.meta.aux.filter((oc) => oc < removedOc);
|
||||
entry.value!.meta.aux.splice(aux.length, 0, removedOc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateRow(attr: string, values: string[], index: number) {
|
||||
function updateRow(attr: string, values: string[], index: number) {
|
||||
entry.value!.attrs[attr] = values;
|
||||
if (attr == 'objectClass') {
|
||||
removeObjectClass(values);
|
||||
focus(focused.value);
|
||||
if (attr == "objectClass") {
|
||||
removeObjectClass(values);
|
||||
focus(focused.value);
|
||||
}
|
||||
if (index !== undefined) focus(attr + '-' + index);
|
||||
}
|
||||
if (index !== undefined) focus(attr + "-" + index);
|
||||
}
|
||||
|
||||
function addMandatoryRows() : string | undefined {
|
||||
const must = attributes('must')
|
||||
.filter(attr => !entry.value!.attrs[attr]);
|
||||
must.forEach(attr => entry.value!.attrs[attr] = ['']);
|
||||
return must.length ? must[0] + '-0' : undefined;
|
||||
}
|
||||
function addMandatoryRows(): string | undefined {
|
||||
const must = attributes("must").filter((attr) => !entry.value!.attrs[attr]);
|
||||
must.forEach((attr) => (entry.value!.attrs[attr] = [""]));
|
||||
return must.length ? must[0] + "-0" : undefined;
|
||||
}
|
||||
|
||||
// Load an entry into the editing form
|
||||
async function load(dn: string, changed: string[] | undefined, focused: string | undefined) {
|
||||
// Load an entry into the editing form
|
||||
async function load(
|
||||
dn: string,
|
||||
changed: string[] | undefined,
|
||||
focused: string | undefined
|
||||
) {
|
||||
invalid.value = [];
|
||||
|
||||
if (!dn || dn.startsWith('-')) {
|
||||
entry.value = undefined;
|
||||
return;
|
||||
if (!dn || dn.startsWith("-")) {
|
||||
entry.value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('api/entry/' + dn)
|
||||
const response = await fetch("api/entry/" + dn);
|
||||
if (!response.ok) return;
|
||||
entry.value = await response.json() as Entry;
|
||||
entry.value = (await response.json()) as Entry;
|
||||
|
||||
entry.value.changed = changed || [];
|
||||
entry.value.meta.isNew = false;
|
||||
|
||||
document.title = dn.split(',')[0];
|
||||
document.title = dn.split(",")[0];
|
||||
focus(focused);
|
||||
}
|
||||
}
|
||||
|
||||
function hasChanged(key: string) {
|
||||
function hasChanged(key: string) {
|
||||
console.log(entry.value?.changed);
|
||||
return entry.value?.changed && entry.value.changed.includes(key) || false
|
||||
}
|
||||
return (entry.value?.changed && entry.value.changed.includes(key)) || false;
|
||||
}
|
||||
|
||||
// Submit the entry form via AJAX
|
||||
async function save() {
|
||||
// Submit the entry form via AJAX
|
||||
async function save() {
|
||||
if (invalid.value.length > 0) {
|
||||
focus(focused.value);
|
||||
return;
|
||||
focus(focused.value);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.value!.changed = [];
|
||||
const response = await fetch('api/entry/' + entry.value!.meta.dn, {
|
||||
method: entry.value!.meta.isNew ? 'PUT' : 'POST',
|
||||
body: JSON.stringify(entry.value!.attrs),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
const response = await fetch("api/entry/" + entry.value!.meta.dn, {
|
||||
method: entry.value!.meta.isNew ? "PUT" : "POST",
|
||||
body: JSON.stringify(entry.value!.attrs),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json() as { changed: string[] };
|
||||
|
||||
const data = (await response.json()) as { changed: string[] };
|
||||
if (data.changed && data.changed.length) {
|
||||
app?.showInfo('👍 Saved changes');
|
||||
app?.showInfo("👍 Saved changes");
|
||||
}
|
||||
if (entry.value!.meta.isNew) {
|
||||
entry.value!.meta.isNew = false;
|
||||
emit('update:activeDn', entry.value!.meta.dn);
|
||||
}
|
||||
else load(entry.value!.meta.dn, data.changed, focused.value);
|
||||
}
|
||||
entry.value!.meta.isNew = false;
|
||||
emit("update:activeDn", entry.value!.meta.dn);
|
||||
} else load(entry.value!.meta.dn, data.changed, focused.value);
|
||||
}
|
||||
|
||||
async function renameEntry(rdn: string) {
|
||||
await fetch('api/rename', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
dn: entry.value!.meta.dn,
|
||||
rdn: rdn
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
async function renameEntry(rdn: string) {
|
||||
await fetch("api/rename", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
dn: entry.value!.meta.dn,
|
||||
rdn: rdn,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const dnparts = entry.value!.meta.dn.split(',');
|
||||
const dnparts = entry.value!.meta.dn.split(",");
|
||||
dnparts.splice(0, 1, rdn);
|
||||
emit('update:activeDn', dnparts.join(','));
|
||||
}
|
||||
emit("update:activeDn", dnparts.join(","));
|
||||
}
|
||||
|
||||
async function deleteEntry(dn: string) {
|
||||
const response = await fetch('api/entry/' + dn, { method: 'DELETE' });
|
||||
if (response.ok && await response.json() !== undefined) {
|
||||
app?.showInfo('Deleted: ' + dn);
|
||||
emit('update:activeDn', '-' + dn);
|
||||
async function deleteEntry(dn: string) {
|
||||
const response = await fetch("api/entry/" + dn, { method: "DELETE" });
|
||||
if (response.ok && (await response.json()) !== undefined) {
|
||||
app?.showInfo("Deleted: " + dn);
|
||||
emit("update:activeDn", "-" + dn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(oldPass: string, newPass: string) {
|
||||
const response = await fetch('api/entry/password/' + entry.value!.meta.dn, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ old: oldPass, new1: newPass }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
async function changePassword(oldPass: string, newPass: string) {
|
||||
const response = await fetch("api/entry/password/" + entry.value!.meta.dn, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ old: oldPass, new1: newPass }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json() as string;
|
||||
|
||||
const data = (await response.json()) as string;
|
||||
|
||||
if (data !== undefined) {
|
||||
entry.value!.attrs.userPassword = [ data ];
|
||||
entry.value!.changed?.push('userPassword');
|
||||
entry.value!.attrs.userPassword = [data];
|
||||
entry.value!.changed?.push("userPassword");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download LDIF
|
||||
async function ldif() {
|
||||
const response = await fetch('api/ldif/' + entry.value!.meta.dn);
|
||||
// Download LDIF
|
||||
async function ldif() {
|
||||
const response = await fetch("api/ldif/" + entry.value!.meta.dn);
|
||||
if (!response.ok) return;
|
||||
|
||||
const a = document.createElement("a"),
|
||||
url = URL.createObjectURL(await response.blob());
|
||||
a.href = url;
|
||||
a.download = entry.value!.meta.dn.split(',')[0].split('=')[1] + '.ldif';
|
||||
a.download = entry.value!.meta.dn.split(",")[0].split("=")[1] + ".ldif";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}
|
||||
|
||||
function attributes(kind : 'must' | 'may') {
|
||||
const attrs = entry.value!.attrs.objectClass
|
||||
.filter(oc => oc && oc != 'top')
|
||||
.map(oc => app?.schema?.oc(oc))
|
||||
.flatMap(oc => oc ? oc.$collect(kind): [])
|
||||
.filter(unique);
|
||||
}
|
||||
|
||||
function attributes(kind: "must" | "may") {
|
||||
const attrs = entry
|
||||
.value!.attrs.objectClass.filter((oc) => oc && oc != "top")
|
||||
.map((oc) => app?.schema?.oc(oc))
|
||||
.flatMap((oc) => (oc ? oc.$collect(kind) : []))
|
||||
.filter(unique);
|
||||
attrs.sort();
|
||||
return attrs;
|
||||
}
|
||||
}
|
||||
|
||||
function valid(key: string, valid: boolean) {
|
||||
function valid(key: string, valid: boolean) {
|
||||
if (valid) {
|
||||
const pos = invalid.value.indexOf(key);
|
||||
if (pos >= 0) invalid.value.splice(pos, 1);
|
||||
const pos = invalid.value.indexOf(key);
|
||||
if (pos >= 0) invalid.value.splice(pos, 1);
|
||||
} else if (!invalid.value.includes(key)) {
|
||||
invalid.value.push(key);
|
||||
}
|
||||
else if (!invalid.value.includes(key)) {
|
||||
invalid.value.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,89 +1,103 @@
|
||||
<template>
|
||||
<modal title="New entry" :open="modal == 'new-entry'" :return-to="returnTo"
|
||||
@ok="onOk" @cancel="emit('update:modal')"
|
||||
@show="init" @shown="select?.focus()">
|
||||
|
||||
<label>Object class:
|
||||
<select ref="select" v-model="objectClass">
|
||||
<template v-for="cls in app?.schema?.objectClasses.values()" :key="cls.name">
|
||||
<option v-if="cls.structural">{{ cls }}</option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label v-if="objectClass">RDN attribute:
|
||||
<select v-model="rdn">
|
||||
<option v-for="rdn in rdns()" :key="rdn">
|
||||
{{ rdn }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<modal
|
||||
title="New entry"
|
||||
:open="modal == 'new-entry'"
|
||||
:return-to="returnTo"
|
||||
@ok="onOk"
|
||||
@cancel="emit('update:modal')"
|
||||
@show="init"
|
||||
@shown="select?.focus()"
|
||||
>
|
||||
<label
|
||||
>Object class:
|
||||
<select ref="select" v-model="objectClass">
|
||||
<template
|
||||
v-for="cls in app?.schema?.objectClasses.values()"
|
||||
:key="cls.name"
|
||||
>
|
||||
<option v-if="cls.structural">{{ cls }}</option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<input v-if="objectClass" v-model="name"
|
||||
placeholder="RDN value" @keyup.enter="onOk" />
|
||||
</modal>
|
||||
<label v-if="objectClass"
|
||||
>RDN attribute:
|
||||
<select v-model="rdn">
|
||||
<option v-for="rdn in rdns()" :key="rdn">
|
||||
{{ rdn }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<input
|
||||
v-if="objectClass"
|
||||
v-model="name"
|
||||
placeholder="RDN value"
|
||||
@keyup.enter="onOk"
|
||||
/>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import type { Provided } from '../Provided';
|
||||
import type { Entry } from './Entry';
|
||||
import { computed, inject, ref } from "vue";
|
||||
import Modal from "../ui/Modal.vue";
|
||||
import type { Provided } from "../Provided";
|
||||
import type { Entry } from "./Entry";
|
||||
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
}),
|
||||
app = inject<Provided>('app'),
|
||||
objectClass = ref(''),
|
||||
rdn = ref(''),
|
||||
name = ref(''),
|
||||
app = inject<Provided>("app"),
|
||||
objectClass = ref(""),
|
||||
rdn = ref(""),
|
||||
name = ref(""),
|
||||
select = ref<HTMLSelectElement | null>(null),
|
||||
oc = computed(() => app?.schema?.oc(objectClass.value)),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
emit = defineEmits(["ok", "update:modal"]);
|
||||
|
||||
function init() {
|
||||
objectClass.value = rdn.value = name.value = '';
|
||||
}
|
||||
function init() {
|
||||
objectClass.value = rdn.value = name.value = "";
|
||||
}
|
||||
|
||||
// Create a new entry in the main editor
|
||||
function onOk() {
|
||||
// Create a new entry in the main editor
|
||||
function onOk() {
|
||||
if (!objectClass.value || !rdn.value || !name.value) return;
|
||||
|
||||
emit('update:modal');
|
||||
emit("update:modal");
|
||||
|
||||
const objectClasses = [objectClass.value];
|
||||
for (let o = oc.value?.$super; o; o = o.$super) {
|
||||
if (!o.structural && o.kind != 'abstract') {
|
||||
objectClasses.push(o.name!);
|
||||
}
|
||||
if (!o.structural && o.kind != "abstract") {
|
||||
objectClasses.push(o.name!);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const entry: Entry = {
|
||||
meta: {
|
||||
dn: rdn.value + '=' + name.value + ',' + props.dn,
|
||||
aux: [],
|
||||
required: [],
|
||||
binary: [],
|
||||
// hints: {},
|
||||
autoFilled: [],
|
||||
isNew: true,
|
||||
},
|
||||
attrs: {
|
||||
objectClass: objectClasses,
|
||||
},
|
||||
changed: [],
|
||||
meta: {
|
||||
dn: rdn.value + "=" + name.value + "," + props.dn,
|
||||
aux: [],
|
||||
required: [],
|
||||
binary: [],
|
||||
// hints: {},
|
||||
autoFilled: [],
|
||||
isNew: true,
|
||||
},
|
||||
attrs: {
|
||||
objectClass: objectClasses,
|
||||
},
|
||||
changed: [],
|
||||
};
|
||||
entry.attrs[rdn.value] = [name.value];
|
||||
emit('ok', entry);
|
||||
}
|
||||
|
||||
// Choice list of RDN attributes for a new entry
|
||||
function rdns() {
|
||||
emit("ok", entry);
|
||||
}
|
||||
|
||||
// Choice list of RDN attributes for a new entry
|
||||
function rdns() {
|
||||
if (!objectClass.value) return [];
|
||||
const ocs = oc.value?.$collect('must') || [];
|
||||
const ocs = oc.value?.$collect("must") || [];
|
||||
if (ocs.length == 1) rdn.value = ocs[0];
|
||||
return ocs;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,87 +1,119 @@
|
||||
<template>
|
||||
<modal title="Change / verify password" :open="modal == 'change-password'" :return-to="returnTo"
|
||||
@show="init" @shown="focus" @ok="onOk"
|
||||
@cancel="emit('update:modal')" @hidden="emit('update-form')">
|
||||
|
||||
<div v-if="oldExists">
|
||||
<small >{{ currentUser ? 'Required' : 'Optional' }}</small>
|
||||
<i v-if="passwordOk !== undefined" class="fa ml-2"
|
||||
:class="passwordOk ? 'text-emerald-700 fa-check-circle' : 'text-danger fa-times-circle'"></i>
|
||||
|
||||
<input ref="old" v-model="oldPassword"
|
||||
placeholder="Old password" type="password" @change="check" />
|
||||
</div>
|
||||
<modal
|
||||
title="Change / verify password"
|
||||
:open="modal == 'change-password'"
|
||||
:return-to="returnTo"
|
||||
@show="init"
|
||||
@shown="focus"
|
||||
@ok="onOk"
|
||||
@cancel="emit('update:modal')"
|
||||
@hidden="emit('update-form')"
|
||||
>
|
||||
<div v-if="oldExists">
|
||||
<small>{{ currentUser ? "Required" : "Optional" }}</small>
|
||||
<i
|
||||
v-if="passwordOk !== undefined"
|
||||
class="fa ml-2"
|
||||
:class="
|
||||
passwordOk
|
||||
? 'text-emerald-700 fa-check-circle'
|
||||
: 'text-danger fa-times-circle'
|
||||
"
|
||||
></i>
|
||||
|
||||
<input ref="changed" v-model="newPassword" placeholder="New password" type="password" />
|
||||
<input
|
||||
ref="old"
|
||||
v-model="oldPassword"
|
||||
placeholder="Old password"
|
||||
type="password"
|
||||
@change="check"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input v-model="repeated" :class="{ 'text-danger': repeated && !passwordsMatch }"
|
||||
placeholder="Repeat new password" type="password" @keyup.enter="onOk" />
|
||||
</modal>
|
||||
<input
|
||||
ref="changed"
|
||||
v-model="newPassword"
|
||||
placeholder="New password"
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-model="repeated"
|
||||
:class="{ 'text-danger': repeated && !passwordsMatch }"
|
||||
placeholder="Repeat new password"
|
||||
type="password"
|
||||
@keyup.enter="onOk"
|
||||
/>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import { computed, ref } from "vue";
|
||||
import Modal from "../ui/Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
user: String,
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
user: String,
|
||||
}),
|
||||
|
||||
oldPassword = ref(''),
|
||||
newPassword = ref(''),
|
||||
repeated = ref(''),
|
||||
oldPassword = ref(""),
|
||||
newPassword = ref(""),
|
||||
repeated = ref(""),
|
||||
passwordOk = ref<boolean>(),
|
||||
|
||||
old = ref<HTMLInputElement | null>(null),
|
||||
changed = ref<HTMLInputElement | null>(null),
|
||||
|
||||
currentUser = computed(() => props.user == props.entry.meta.dn),
|
||||
passwordsMatch = computed(() => newPassword.value && newPassword.value == repeated.value),
|
||||
oldExists = computed(() => !!props.entry.attrs.userPassword
|
||||
&& props.entry.attrs.userPassword[0] != ''),
|
||||
passwordsMatch = computed(
|
||||
() => newPassword.value && newPassword.value == repeated.value
|
||||
),
|
||||
oldExists = computed(
|
||||
() =>
|
||||
!!props.entry.attrs.userPassword &&
|
||||
props.entry.attrs.userPassword[0] != ""
|
||||
),
|
||||
emit = defineEmits(["ok", "update-form", "update:modal"]);
|
||||
|
||||
emit = defineEmits(['ok', 'update-form', 'update:modal']);
|
||||
|
||||
function init() {
|
||||
oldPassword.value = newPassword.value = repeated.value = '';
|
||||
function init() {
|
||||
oldPassword.value = newPassword.value = repeated.value = "";
|
||||
passwordOk.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function focus() {
|
||||
function focus() {
|
||||
if (oldExists.value) old.value?.focus();
|
||||
else changed.value?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Verify an existing password
|
||||
// This is optional for administrative changes
|
||||
// but required to change the current user's password
|
||||
async function check() {
|
||||
// Verify an existing password
|
||||
// This is optional for administrative changes
|
||||
// but required to change the current user's password
|
||||
async function check() {
|
||||
if (!oldPassword.value || oldPassword.value.length == 0) {
|
||||
passwordOk.value = undefined;
|
||||
return;
|
||||
passwordOk.value = undefined;
|
||||
return;
|
||||
}
|
||||
const response = await fetch('api/entry/password/' + props.entry.meta.dn, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ check: oldPassword.value }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
const response = await fetch("api/entry/password/" + props.entry.meta.dn, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ check: oldPassword.value }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
passwordOk.value = await response.json() as boolean;
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
passwordOk.value = (await response.json()) as boolean;
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
// old and new passwords are required for current user
|
||||
// new passwords must match
|
||||
if ((currentUser.value && !newPassword.value)
|
||||
|| newPassword.value != repeated.value
|
||||
|| (currentUser.value && oldExists.value && !passwordOk.value)) return;
|
||||
if (
|
||||
(currentUser.value && !newPassword.value) ||
|
||||
newPassword.value != repeated.value ||
|
||||
(currentUser.value && oldExists.value && !passwordOk.value)
|
||||
)
|
||||
return;
|
||||
|
||||
emit('update:modal');
|
||||
emit('ok', oldPassword.value, newPassword.value);
|
||||
}
|
||||
emit("update:modal");
|
||||
emit("ok", oldPassword.value, newPassword.value);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,45 +1,50 @@
|
||||
<template>
|
||||
<modal title="Rename entry" :open="modal == 'rename-entry'" :return-to="returnTo"
|
||||
@ok="onOk" @cancel="emit('update:modal')"
|
||||
@show="init" @shown="select?.focus()">
|
||||
|
||||
<label>New RDN attribute:
|
||||
<select ref="select" v-model="rdn" @keyup.enter="onOk">
|
||||
<option v-for="rdn in rdns" :key="rdn">{{ rdn }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</modal>
|
||||
<modal
|
||||
title="Rename entry"
|
||||
:open="modal == 'rename-entry'"
|
||||
:return-to="returnTo"
|
||||
@ok="onOk"
|
||||
@cancel="emit('update:modal')"
|
||||
@show="init"
|
||||
@shown="select?.focus()"
|
||||
>
|
||||
<label
|
||||
>New RDN attribute:
|
||||
<select ref="select" v-model="rdn" @keyup.enter="onOk">
|
||||
<option v-for="rdn in rdns" :key="rdn">{{ rdn }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import { computed, ref } from "vue";
|
||||
import Modal from "../ui/Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
}),
|
||||
|
||||
rdn = ref<string>(),
|
||||
select = ref<HTMLInputElement | null>(null),
|
||||
rdns = computed(() => Object.keys(props.entry.attrs).filter(ok)),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
emit = defineEmits(["ok", "update:modal"]);
|
||||
|
||||
function init() {
|
||||
function init() {
|
||||
rdn.value = rdns.value.length == 1 ? rdns.value[0] : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
const rdnAttr = props.entry.attrs[rdn.value || ''];
|
||||
if (rdnAttr && rdnAttr[0]) {
|
||||
emit('update:modal');
|
||||
emit('ok', rdn.value + '=' + rdnAttr[0]);
|
||||
function onOk() {
|
||||
const rdnAttr = props.entry.attrs[rdn.value || ""];
|
||||
if (rdnAttr && rdnAttr[0]) {
|
||||
emit("update:modal");
|
||||
emit("ok", rdn.value + "=" + rdnAttr[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ok(key: string) {
|
||||
const rdn = props.entry.meta.dn.split('=')[0];
|
||||
function ok(key: string) {
|
||||
const rdn = props.entry.meta.dn.split("=")[0];
|
||||
return key != rdn && !props.entry.attrs[key].every((val: unknown) => !val);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,28 +1,39 @@
|
||||
<template>
|
||||
<card v-if="modelValue && attr" :title="attr.names?.join(', ') || ''" class="ml-4" @close="emit('update:modelValue')">
|
||||
<card
|
||||
v-if="modelValue && attr"
|
||||
:title="attr.names?.join(', ') || ''"
|
||||
class="ml-4"
|
||||
@close="emit('update:modelValue')"
|
||||
>
|
||||
<div class="header">{{ attr.desc }}</div>
|
||||
|
||||
<div class="header">{{ attr.desc }}</div>
|
||||
|
||||
<ul class="list-disc mt-2">
|
||||
<li v-if="attr.$super">Parent:
|
||||
<span class="cursor-pointer"
|
||||
@click="emit('update:modelValue', attr.$super.name)">{{ attr.$super }}</span>
|
||||
</li>
|
||||
<li v-if="attr.equality">Equality: {{ attr.equality }}</li>
|
||||
<li v-if="attr.ordering">Ordering: {{ attr.ordering }}</li>
|
||||
<li v-if="attr.substr">Substring: {{ attr.substr }}</li>
|
||||
<li>Syntax: {{ attr.$syntax }} <span v-if="attr.binary">(binary)</span></li>
|
||||
</ul>
|
||||
</card>
|
||||
<ul class="list-disc mt-2">
|
||||
<li v-if="attr.$super">
|
||||
Parent:
|
||||
<span
|
||||
class="cursor-pointer"
|
||||
@click="emit('update:modelValue', attr.$super.name)"
|
||||
>{{ attr.$super }}</span
|
||||
>
|
||||
</li>
|
||||
<li v-if="attr.equality">Equality: {{ attr.equality }}</li>
|
||||
<li v-if="attr.ordering">Ordering: {{ attr.ordering }}</li>
|
||||
<li v-if="attr.substr">Substring: {{ attr.substr }}</li>
|
||||
<li>
|
||||
Syntax: {{ attr.$syntax }}
|
||||
<span v-if="attr.binary">(binary)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
import Card from '../ui/Card.vue';
|
||||
import type { Provided } from '../Provided';
|
||||
import { computed, inject } from "vue";
|
||||
import Card from "../ui/Card.vue";
|
||||
import type { Provided } from "../Provided";
|
||||
|
||||
const props = defineProps({ modelValue: String }),
|
||||
app = inject<Provided>('app'),
|
||||
const props = defineProps({ modelValue: String }),
|
||||
app = inject<Provided>("app"),
|
||||
attr = computed(() => app?.schema?.attr(props.modelValue)),
|
||||
emit = defineEmits(['show-attr', 'update:modelValue']);
|
||||
emit = defineEmits(["show-attr", "update:modelValue"]);
|
||||
</script>
|
||||
|
@ -1,41 +1,60 @@
|
||||
<template>
|
||||
<card v-if="modelValue && oc" :title="oc.name || ''" class="ml-4" @close="emit('update:modelValue')">
|
||||
<div class="header">{{ oc.desc }}</div>
|
||||
|
||||
<div v-if="oc.sup?.length" class="mt-2"><i>Superclasses:</i>
|
||||
<ul class="list-disc">
|
||||
<li v-for="name in oc.sup" :key="name">
|
||||
<span class="cursor-pointer" @click="emit('update:modelValue', name)">{{ name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<card
|
||||
v-if="modelValue && oc"
|
||||
:title="oc.name || ''"
|
||||
class="ml-4"
|
||||
@close="emit('update:modelValue')"
|
||||
>
|
||||
<div class="header">{{ oc.desc }}</div>
|
||||
|
||||
<div v-if="oc.$collect('must').length" class="mt-2"><i>Required attributes:</i>
|
||||
<ul class="list-disc">
|
||||
<li v-for="name in oc.$collect('must')" :key="name">
|
||||
<span class="cursor-pointer" @click="emit('show-attr', name)">{{ name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="oc.sup?.length" class="mt-2">
|
||||
<i>Superclasses:</i>
|
||||
<ul class="list-disc">
|
||||
<li v-for="name in oc.sup" :key="name">
|
||||
<span
|
||||
class="cursor-pointer"
|
||||
@click="emit('update:modelValue', name)"
|
||||
>{{ name }}</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="oc.$collect('may').length" class="mt-2"><i>Optional attributes:</i>
|
||||
<ul class="list-disc">
|
||||
<li v-for="name in oc.$collect('may')" :key="name">
|
||||
<span class="cursor-pointer" @click="emit('show-attr', name)">{{ name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="oc.$collect('must').length" class="mt-2">
|
||||
<i>Required attributes:</i>
|
||||
<ul class="list-disc">
|
||||
<li v-for="name in oc.$collect('must')" :key="name">
|
||||
<span
|
||||
class="cursor-pointer"
|
||||
@click="emit('show-attr', name)"
|
||||
>{{ name }}</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</card>
|
||||
<div v-if="oc.$collect('may').length" class="mt-2">
|
||||
<i>Optional attributes:</i>
|
||||
<ul class="list-disc">
|
||||
<li v-for="name in oc.$collect('may')" :key="name">
|
||||
<span
|
||||
class="cursor-pointer"
|
||||
@click="emit('show-attr', name)"
|
||||
>{{ name }}</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
import Card from '../ui/Card.vue';
|
||||
import type { Provided } from '../Provided';
|
||||
import { computed, inject } from "vue";
|
||||
import Card from "../ui/Card.vue";
|
||||
import type { Provided } from "../Provided";
|
||||
|
||||
const props = defineProps({ modelValue: String }),
|
||||
app = inject<Provided>('app'),
|
||||
const props = defineProps({ modelValue: String }),
|
||||
app = inject<Provided>("app"),
|
||||
oc = computed(() => app?.schema?.oc(props.modelValue)),
|
||||
emit = defineEmits(['show-attr', 'show-oc', 'update:modelValue']);
|
||||
emit = defineEmits(["show-attr", "show-oc", "update:modelValue"]);
|
||||
</script>
|
||||
|
@ -1,80 +1,74 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { LdapSchema, DN, Attribute, ObjectClass } from './schema';
|
||||
import json from './test-schema.json';
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { LdapSchema, DN, Attribute, ObjectClass } from "./schema";
|
||||
import json from "./test-schema.json";
|
||||
|
||||
const sut = new LdapSchema(json);
|
||||
|
||||
describe('LDAP schema items', () => {
|
||||
describe('DNs and RDNs', () => {
|
||||
const
|
||||
dn1 = new DN('dc=foo,dc=bar'), rdn1 = dn1.rdn,
|
||||
dn2 = new DN('domainComponent=FOO,domainComponent=BAR'), rdn2 = dn2.rdn,
|
||||
dn3 = new DN('domainComponent=bar'), rdn3 = dn3.rdn;
|
||||
describe("LDAP schema items", () => {
|
||||
describe("DNs and RDNs", () => {
|
||||
const dn1 = new DN("dc=foo,dc=bar"),
|
||||
rdn1 = dn1.rdn;
|
||||
const dn2 = new DN("domainComponent=FOO,domainComponent=BAR"),
|
||||
rdn2 = dn2.rdn;
|
||||
const dn3 = new DN("domainComponent=bar"),
|
||||
rdn3 = dn3.rdn;
|
||||
|
||||
test('Test RDN attribute equality', () =>
|
||||
expect(rdn1.attr).toEqual(sut.attr('domainComponent')));
|
||||
test("Test RDN attribute equality", () =>
|
||||
expect(rdn1.attr).toEqual(sut.attr("domainComponent")));
|
||||
|
||||
test('Test RDN equality', () =>
|
||||
expect(rdn1.eq(rdn2)).toBeTruthy());
|
||||
test("Test RDN equality", () => expect(rdn1.eq(rdn2)).toBeTruthy());
|
||||
|
||||
test('Test RDN inequality', () =>
|
||||
expect(rdn1.eq(rdn3)).toBeFalsy());
|
||||
test("Test RDN inequality", () => expect(rdn1.eq(rdn3)).toBeFalsy());
|
||||
|
||||
test('Test RDN part of DN', () =>
|
||||
expect(dn1.rdn.eq(rdn2)).toBeTruthy());
|
||||
test("Test RDN part of DN", () =>
|
||||
expect(dn1.rdn.eq(rdn2)).toBeTruthy());
|
||||
|
||||
test('Test DN parent', () =>
|
||||
expect(dn2.parent?.eq(dn3)).toBeTruthy());
|
||||
test("Test DN parent", () => expect(dn2.parent?.eq(dn3)).toBeTruthy());
|
||||
|
||||
test('Test DN grandparent', () =>
|
||||
expect(dn3.parent).toBeUndefined());
|
||||
test("Test DN grandparent", () => expect(dn3.parent).toBeUndefined());
|
||||
|
||||
test('Test DN equality', () =>
|
||||
expect(dn1.eq(dn2)).toBeTruthy());
|
||||
});
|
||||
test("Test DN equality", () => expect(dn1.eq(dn2)).toBeTruthy());
|
||||
});
|
||||
|
||||
describe('Attributes', () => {
|
||||
const sn = sut.attr('sn'),
|
||||
name = sut.attr('name');
|
||||
describe("Attributes", () => {
|
||||
const sn = sut.attr("sn"),
|
||||
name = sut.attr("name");
|
||||
|
||||
test('SN is found in schema', () =>
|
||||
expect(sn).toBeDefined());
|
||||
test("SN is found in schema", () => expect(sn).toBeDefined());
|
||||
|
||||
test('SN has name as prototype', () =>
|
||||
expect(Object.getPrototypeOf(sn)).toEqual(name));
|
||||
test("SN has name as prototype", () =>
|
||||
expect(Object.getPrototypeOf(sn)).toEqual(name));
|
||||
|
||||
test('SN is an Attribute', () =>
|
||||
expect(sn).toBeInstanceOf(Attribute));
|
||||
test("SN is an Attribute", () => expect(sn).toBeInstanceOf(Attribute));
|
||||
|
||||
test('SN has no own equality', () =>
|
||||
expect(Object.getOwnPropertyNames(sn)).not.toContain('equality'));
|
||||
test("SN has no own equality", () =>
|
||||
expect(Object.getOwnPropertyNames(sn)).not.toContain("equality"));
|
||||
|
||||
test('SN inherits equality from name', () =>
|
||||
expect(sn?.equality).toBeDefined());
|
||||
test("SN inherits equality from name", () =>
|
||||
expect(sn?.equality).toBeDefined());
|
||||
|
||||
test('SN syntax resolution', () =>
|
||||
expect(sn?.$syntax?.toString()).toEqual('Directory String'));
|
||||
test("SN syntax resolution", () =>
|
||||
expect(sn?.$syntax?.toString()).toEqual("Directory String"));
|
||||
|
||||
test('Search for SN', () =>
|
||||
expect(sut.search('sur')).toEqual([sn]));
|
||||
});
|
||||
test("Search for SN", () => expect(sut.search("sur")).toEqual([sn]));
|
||||
});
|
||||
|
||||
describe('ObjectClass inheritance', () => {
|
||||
const top = sut.oc('top'),
|
||||
dnsDomain = sut.oc('dnsDomain');
|
||||
describe("ObjectClass inheritance", () => {
|
||||
const top = sut.oc("top"),
|
||||
dnsDomain = sut.oc("dnsDomain");
|
||||
|
||||
function superClasses(cls: ObjectClass | undefined) : ObjectClass[] {
|
||||
const result = [];
|
||||
for (let oc = cls; oc; oc = Object.getPrototypeOf(oc)) {
|
||||
result.push(oc);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function superClasses(cls: ObjectClass | undefined): ObjectClass[] {
|
||||
const result = [];
|
||||
for (let oc = cls; oc; oc = Object.getPrototypeOf(oc)) {
|
||||
result.push(oc);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
test('top is an ObjectClass', () =>
|
||||
expect(top).toBeInstanceOf(ObjectClass));
|
||||
test("top is an ObjectClass", () =>
|
||||
expect(top).toBeInstanceOf(ObjectClass));
|
||||
|
||||
test('dnsDomain inherits from top', () =>
|
||||
expect(superClasses(dnsDomain)).toContain(top));
|
||||
});
|
||||
test("dnsDomain inherits from top", () =>
|
||||
expect(superClasses(dnsDomain)).toContain(top));
|
||||
});
|
||||
});
|
||||
|
@ -1,23 +1,28 @@
|
||||
<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" title="close"
|
||||
@click="emit('close')">⊗
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<div class="px-6 py-2">
|
||||
<slot></slot>
|
||||
<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"
|
||||
title="close"
|
||||
@click="emit('close')"
|
||||
>⊗
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<div class="px-6 py-2">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
});
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
</script>
|
||||
const emit = defineEmits(["close"]);
|
||||
</script>
|
||||
|
@ -1,24 +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.stop="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-model:open="open">
|
||||
<slot></slot>
|
||||
</popover>
|
||||
</div>
|
||||
<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.stop="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-model:open="open">
|
||||
<slot></slot>
|
||||
</popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Popover from './Popover.vue';
|
||||
import { ref } from "vue";
|
||||
import Popover from "./Popover.vue";
|
||||
|
||||
const open = ref(false);
|
||||
defineProps({ title: String });
|
||||
const open = ref(false);
|
||||
defineProps({ title: String });
|
||||
</script>
|
||||
|
@ -1,98 +1,150 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div ref="backdrop" v-if="open" @click.self="onCancel" @keydown.esc="onCancel"
|
||||
class="fixed w-full h-full top-0 left-0 flex items-center justify-center z-30" >
|
||||
<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="onCancel"
|
||||
@keydown.esc="onCancel"
|
||||
class="fixed w-full h-full top-0 left-0 flex items-center justify-center z-30"
|
||||
>
|
||||
<div
|
||||
class="absolute max-h-full w-1/2 max-w-lg container text-front overflow-hidden rounded bg-back border border-front/40"
|
||||
>
|
||||
<div class="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="ui-modal-header text-xl font-bold leading-normal"
|
||||
>
|
||||
<slot name="header">{{ title }}</slot>
|
||||
</h3>
|
||||
|
||||
<div class="absolute max-h-full w-1/2 max-w-lg container text-front overflow-hidden rounded bg-back border border-front/40">
|
||||
<div class="flex justify-between items-start">
|
||||
<div
|
||||
class="control text-xl"
|
||||
@click="onCancel"
|
||||
title="close"
|
||||
>
|
||||
⊗
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="ui-modal-header text-xl font-bold leading-normal">
|
||||
<slot name="header">{{ title }}</slot>
|
||||
</h3>
|
||||
<div class="ui-modal-body p-4 space-y-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div class="control text-xl" @click="onCancel" title="close">⊗</div>
|
||||
</div>
|
||||
|
||||
<div class="ui-modal-body p-4 space-y-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-show="!hideFooter" class="ui-modal-footer flex justify-end w-full p-4 space-x-3">
|
||||
<slot name="footer">
|
||||
<button id="ui-modal-cancel" @click="onCancel" type="button" class="btn" :class="cancelClasses" tabindex="0">
|
||||
<slot name="modal-cancel">{{ cancelTitle }}</slot>
|
||||
</button>
|
||||
<button id="ui-modal-ok" @click.stop="onOk" type="button" class="btn" :class="okClasses" tabindex="0">
|
||||
<slot name="modal-ok">{{ okTitle }}</slot>
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-show="!hideFooter"
|
||||
class="ui-modal-footer flex justify-end w-full p-4 space-x-3"
|
||||
>
|
||||
<slot name="footer">
|
||||
<button
|
||||
id="ui-modal-cancel"
|
||||
@click="onCancel"
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="cancelClasses"
|
||||
tabindex="0"
|
||||
>
|
||||
<slot name="modal-cancel">{{
|
||||
cancelTitle
|
||||
}}</slot>
|
||||
</button>
|
||||
<button
|
||||
id="ui-modal-ok"
|
||||
@click.stop="onOk"
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="okClasses"
|
||||
tabindex="0"
|
||||
>
|
||||
<slot name="modal-ok">{{
|
||||
okTitle
|
||||
}}</slot>
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
open: { type: Boolean, required: true },
|
||||
okTitle: { type: String, default: 'OK' },
|
||||
okClasses: { type: String, default: 'bg-primary/80' },
|
||||
cancelTitle: { type: String, default: 'Cancel' },
|
||||
cancelClasses: { type: String, default: 'bg-secondary' },
|
||||
hideFooter: { type: Boolean, default: false },
|
||||
returnTo: String,
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
open: { type: Boolean, required: true },
|
||||
okTitle: { type: String, default: "OK" },
|
||||
okClasses: { type: String, default: "bg-primary/80" },
|
||||
cancelTitle: { type: String, default: "Cancel" },
|
||||
cancelClasses: { type: String, default: "bg-secondary" },
|
||||
hideFooter: { type: Boolean, default: false },
|
||||
returnTo: String,
|
||||
}),
|
||||
emit = defineEmits(['ok', 'cancel', 'show', 'shown', 'hide', 'hidden']);
|
||||
emit = defineEmits(["ok", "cancel", "show", "shown", "hide", "hidden"]);
|
||||
|
||||
function onOk() {
|
||||
if (props.open) emit('ok');
|
||||
}
|
||||
function onOk() {
|
||||
if (props.open) emit("ok");
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
function onCancel() {
|
||||
if (props.open) {
|
||||
if (props.returnTo) document.getElementById(props.returnTo)?.focus();
|
||||
emit('cancel');
|
||||
if (props.returnTo) document.getElementById(props.returnTo)?.focus();
|
||||
emit("cancel");
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ui-modal-body label {
|
||||
.ui-modal-body label {
|
||||
@apply block text-front/70;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-modal-body input, .ui-modal-body textarea, .ui-modal-body select {
|
||||
.ui-modal-body input,
|
||||
.ui-modal-body textarea,
|
||||
.ui-modal-body select {
|
||||
@apply w-full border border-front/20 rounded p-2 mt-1 outline-none focus:border-primary text-front bg-gray-200/80 dark:bg-gray-800/80;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-modal-footer button {
|
||||
.ui-modal-footer button {
|
||||
min-width: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bounce-enter-active {
|
||||
.bounce-enter-active {
|
||||
animation: bounce-in 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
.bounce-leave-active {
|
||||
.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>
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,96 +1,104 @@
|
||||
<template>
|
||||
<transition name="fade" @after-enter="emit('opened')" @after-leave="emit('closed')">
|
||||
<div v-if="open"
|
||||
class="ui-popover absolute z-10 border border-front/70 rounded min-w-max text-front bg-back list-none">
|
||||
<ul class="bg-front/5 dark:bg-front/10 py-2" ref="items" @click="close">
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</div>
|
||||
</transition>
|
||||
<transition
|
||||
name="fade"
|
||||
@after-enter="emit('opened')"
|
||||
@after-leave="emit('closed')"
|
||||
>
|
||||
<div
|
||||
v-if="open"
|
||||
class="ui-popover absolute z-10 border border-front/70 rounded min-w-max text-front bg-back list-none"
|
||||
>
|
||||
<ul
|
||||
class="bg-front/5 dark:bg-front/10 py-2"
|
||||
ref="items"
|
||||
@click="close"
|
||||
>
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useEventListener, useMouseInElement } from '@vueuse/core';
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useEventListener, useMouseInElement } from "@vueuse/core";
|
||||
|
||||
const props = defineProps({ open: Boolean }),
|
||||
emit = defineEmits(['opened', 'closed', 'update:open']),
|
||||
const props = defineProps({ open: Boolean }),
|
||||
emit = defineEmits(["opened", "closed", "update:open"]),
|
||||
items = ref<HTMLElement | null>(null),
|
||||
selected = ref<number>(),
|
||||
{ isOutside } = useMouseInElement(items);
|
||||
|
||||
function close() {
|
||||
function close() {
|
||||
selected.value = undefined;
|
||||
if (props.open) emit('update:open');
|
||||
}
|
||||
if (props.open) emit("update:open");
|
||||
}
|
||||
|
||||
function move(offset: number) {
|
||||
function move(offset: number) {
|
||||
const maxpos = items.value!.children.length - 1;
|
||||
if (selected.value === undefined) {
|
||||
selected.value = offset > 0 ? 0 : maxpos;
|
||||
selected.value = offset > 0 ? 0 : maxpos;
|
||||
} else {
|
||||
selected.value += offset;
|
||||
if (selected.value > maxpos) selected.value = 0;
|
||||
else if (selected.value < 0) selected.value = maxpos;
|
||||
}
|
||||
else {
|
||||
selected.value += offset;
|
||||
if (selected.value > maxpos) selected.value = 0;
|
||||
else if (selected.value < 0) selected.value = maxpos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scroll(e: KeyboardEvent) {
|
||||
function scroll(e: KeyboardEvent) {
|
||||
if (!props.open || !items.value) return;
|
||||
switch (e.key) {
|
||||
case 'Esc':
|
||||
case 'Escape':
|
||||
close();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
move(1);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
move(-1);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'Enter': {
|
||||
const target = items.value.children[selected.value!] as HTMLElement;
|
||||
target.click();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case "Esc":
|
||||
case "Escape":
|
||||
close();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
move(1);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
move(-1);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case "Enter": {
|
||||
const target = items.value.children[selected.value!] as HTMLElement;
|
||||
target.click();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
useEventListener(document, 'keydown', scroll);
|
||||
useEventListener(document, 'click', close);
|
||||
});
|
||||
onMounted(() => {
|
||||
useEventListener(document, "keydown", scroll);
|
||||
useEventListener(document, "click", close);
|
||||
});
|
||||
|
||||
watch(selected, (pos) => {
|
||||
watch(selected, (pos) => {
|
||||
if (!props.open || !items.value) return;
|
||||
for (const child of items.value.children) {
|
||||
child.classList.remove('selected');
|
||||
child.classList.remove("selected");
|
||||
}
|
||||
if (pos != undefined) items.value.children[pos].classList.add('selected');
|
||||
});
|
||||
if (pos != undefined) items.value.children[pos].classList.add("selected");
|
||||
});
|
||||
|
||||
watch(isOutside, (outside) => {
|
||||
watch(isOutside, (outside) => {
|
||||
for (const child of items.value!.children) {
|
||||
if (outside) {
|
||||
child.classList.remove('hover:bg-primary/40');
|
||||
}
|
||||
else {
|
||||
selected.value = undefined;
|
||||
child.classList.add('hover:bg-primary/40');
|
||||
}
|
||||
if (outside) {
|
||||
child.classList.remove("hover:bg-primary/40");
|
||||
} else {
|
||||
selected.value = undefined;
|
||||
child.classList.add("hover:bg-primary/40");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ui-popover [role=menuitem] {
|
||||
.ui-popover [role="menuitem"] {
|
||||
@apply cursor-pointer px-4;
|
||||
}
|
||||
.ui-popover [role=menuitem].selected {
|
||||
}
|
||||
.ui-popover [role="menuitem"].selected {
|
||||
@apply bg-primary/40;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
</style>
|
||||
|
@ -1,19 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps(['value']),
|
||||
emit = defineEmits(['update:value']),
|
||||
on = computed(() => props.value == 'TRUE');
|
||||
|
||||
const props = defineProps(["value"]),
|
||||
emit = defineEmits(["update:value"]),
|
||||
on = computed(() => props.value == "TRUE");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="p-0 relative focus:outline-none" tabindex="0"
|
||||
@click="emit('update:value', !on ? 'TRUE' : 'FALSE')">
|
||||
<div class="w-8 h-4 transition rounded-full bg-gray-200"></div>
|
||||
<div class="absolute top-0 left-0 w-4 h-4 transition-all duration-200 ease-in-out transform scale-110 rounded-full shadow-sm"
|
||||
:class="on ? 'translate-x-4 bg-primary' : 'translate-x-0 bg-secondary'">
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-0 relative focus:outline-none"
|
||||
tabindex="0"
|
||||
@click="emit('update:value', !on ? 'TRUE' : 'FALSE')"
|
||||
>
|
||||
<div class="w-8 h-4 transition rounded-full bg-gray-200"></div>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-4 h-4 transition-all duration-200 ease-in-out transform scale-110 rounded-full shadow-sm"
|
||||
:class="
|
||||
on ? 'translate-x-4 bg-primary' : 'translate-x-0 bg-secondary'
|
||||
"
|
||||
></div>
|
||||
</button>
|
||||
</template>
|
||||
|
@ -3,19 +3,19 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-front: 32 32 32;
|
||||
--color-back: 255 255 255;
|
||||
|
||||
--color-primary: 23 162 184;
|
||||
--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;
|
||||
--color-front: 32 32 32;
|
||||
--color-back: 255 255 255;
|
||||
|
||||
--color-primary: 23 162 184;
|
||||
--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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user