Reformat everything Vue to 4 spaces like a jerk

This commit is contained in:
Saphire 2024-07-16 22:17:02 +06:00
parent 9ed1ebb828
commit eb25fa0ba4
Signed by: Saphire
GPG Key ID: B26EB7A1F07044C4
31 changed files with 2244 additions and 1639 deletions

View File

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

View File

@ -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"
]
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import type { LdapSchema } from './schema/schema';
import type { LdapSchema } from "./schema/schema";
export interface Provided {
readonly schema?: LdapSchema;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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