Migrate to TypeScript
This commit is contained in:
parent
e6de333716
commit
d2ec7ed9b5
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
2
app.py
2
app.py
@ -243,7 +243,7 @@ def _entry(res: Tuple[str, Any]) -> Dict[str, Any]:
|
||||
for a in must_attrs],
|
||||
'aux': sorted(aux - ocs),
|
||||
'binary': sorted(binary),
|
||||
'hints': {},
|
||||
'hints': {}, # FIXME obsolete?
|
||||
'autoFilled': [],
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,7 @@
|
||||
|
||||
<meta name="theme-color" content="aliceblue" />
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<title>Directory</title>
|
||||
</head>
|
||||
|
BIN
package-lock.json
generated
BIN
package-lock.json
generated
Binary file not shown.
49
package.json
49
package.json
@ -2,29 +2,43 @@
|
||||
"name": "ldap-ui",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest",
|
||||
"build": "vite build",
|
||||
"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.2.37"
|
||||
"vue": "^3.4.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4",
|
||||
"@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.15.1",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"tailwind-config-viewer": "^1.7.2",
|
||||
"tailwindcss": "^3.3",
|
||||
"vite": "^4",
|
||||
"vite-plugin-compression": "^0.5.0",
|
||||
"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": {
|
||||
@ -35,10 +49,27 @@
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/vue3-essential"
|
||||
"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": [
|
||||
|
54
src/App.vue
54
src/App.vue
@ -36,38 +36,46 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, provide, readonly, ref, watch } from 'vue';
|
||||
<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.js';
|
||||
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 { request } from './request.js';
|
||||
import type { Provided } from './components/Provided';
|
||||
import { request } from './request';
|
||||
import type { Options } from './request';
|
||||
import TreeView from './components/TreeView.vue';
|
||||
|
||||
interface Error {
|
||||
counter: number;
|
||||
cssClass: string;
|
||||
msg: string
|
||||
}
|
||||
|
||||
const
|
||||
// Authentication
|
||||
user = ref(null), // logged in user
|
||||
baseDn = ref(null),
|
||||
user = ref<string>(), // logged in user
|
||||
baseDn = ref<string>(),
|
||||
|
||||
// Components
|
||||
treeOpen = ref(true), // Is the tree visible?
|
||||
activeDn = ref(null), // currently active DN in the editor
|
||||
modal = ref(null), // 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(null), // status alert
|
||||
error = ref<Error>(), // status alert
|
||||
|
||||
// LDAP schema
|
||||
schema = ref(null),
|
||||
oc = ref(null), // objectClass info in side panel
|
||||
attr = ref(null), // attribute info in side panel
|
||||
schema = ref<LdapSchema>(),
|
||||
oc = ref<string>(), // objectClass info in side panel
|
||||
attr = ref<string>(), // attribute info in side panel
|
||||
|
||||
// Helpers for components
|
||||
provided = {
|
||||
get schema() { return readonly(schema.value); },
|
||||
provided: Provided = {
|
||||
get schema() { return schema.value; },
|
||||
showInfo: showInfo,
|
||||
showWarning: showWarning,
|
||||
xhr: xhr,
|
||||
@ -86,7 +94,7 @@
|
||||
watch(attr, (a) => { if (a) oc.value = undefined; });
|
||||
watch(oc, (o) => { if (o) attr.value = undefined; });
|
||||
|
||||
function xhr(options) {
|
||||
function xhr(options: Options) {
|
||||
if (options.data && !options.binary) {
|
||||
if (!options.headers) options.headers = {}
|
||||
if (!options.headers['Content-Type']) {
|
||||
@ -99,24 +107,24 @@
|
||||
}
|
||||
|
||||
// Display an info popup
|
||||
function showInfo(msg) {
|
||||
function showInfo(msg: string) {
|
||||
error.value = { counter: 5, cssClass: 'bg-emerald-300', msg: '' + msg };
|
||||
setTimeout(() => { error.value = null; }, 5000);
|
||||
setTimeout(() => { error.value = undefined; }, 5000);
|
||||
}
|
||||
|
||||
// Flash a warning popup
|
||||
function showWarning(msg) {
|
||||
function showWarning(msg: string) {
|
||||
error.value = { counter: 10, cssClass: 'bg-amber-200', msg: '⚠️ ' + msg };
|
||||
setTimeout(() => { error.value = null; }, 10000);
|
||||
setTimeout(() => { error.value = undefined; }, 10000);
|
||||
}
|
||||
|
||||
// Report an error
|
||||
function showError(msg) {
|
||||
function showError(msg: string) {
|
||||
error.value = { counter: 60, cssClass: 'bg-red-300', msg: '⛔ ' + msg };
|
||||
setTimeout(() => { error.value = null; }, 60000);
|
||||
setTimeout(() => { error.value = undefined; }, 60000);
|
||||
}
|
||||
|
||||
function showException(msg) {
|
||||
function showException(msg: string) {
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = msg.replace("\n", " ");
|
||||
const titles = span.getElementsByTagName('title');
|
||||
|
@ -6,31 +6,32 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from 'vue';
|
||||
import Modal from './ui/Modal.vue';
|
||||
import type { Provided } from './Provided';
|
||||
|
||||
const
|
||||
app = inject('app'),
|
||||
app = inject<Provided>('app'),
|
||||
ldifData = ref(''),
|
||||
ldifFile = ref(null),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
defineProps({ modal: String });
|
||||
|
||||
function init() {
|
||||
ldifData.value = '';
|
||||
ldifFile.value = null;
|
||||
}
|
||||
|
||||
// Load LDIF from file
|
||||
function upload(evt) {
|
||||
const file = evt.target.files[0],
|
||||
function upload(evt: Event) {
|
||||
const target = evt.target as HTMLInputElement,
|
||||
files = target.files as FileList,
|
||||
file = files[0],
|
||||
reader = new FileReader();
|
||||
|
||||
reader.onload = function() {
|
||||
ldifData.value = reader.result;
|
||||
evt.target.value = null;
|
||||
ldifData.value = reader.result as string;
|
||||
target.value = '';
|
||||
}
|
||||
reader.readAsText(file);
|
||||
}
|
||||
@ -40,7 +41,7 @@
|
||||
if (!ldifData.value) return;
|
||||
|
||||
emit('update:modal');
|
||||
const data = await app.xhr({
|
||||
const data = await app?.xhr({
|
||||
url: 'api/ldif',
|
||||
method: 'POST',
|
||||
data: ldifData.value,
|
||||
|
@ -13,15 +13,15 @@
|
||||
<span class="cursor-pointer" @click="emit('show-modal', 'ldif-import')">Import…</span>
|
||||
|
||||
<dropdown-menu title="Schema">
|
||||
<li role="menuitem" v-for="obj in app.schema.ObjectClass.values"
|
||||
:key="obj.name" @click="emit('show-oc', obj.name)">
|
||||
{{ obj.name }}
|
||||
<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 border border-front/80 outline-none text-front dark:bg-gray-800/80"
|
||||
autofocus :placeholder="' \uf002'" name="q" @focusin="input.select();" accesskey="k"
|
||||
<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" />
|
||||
@ -31,18 +31,19 @@
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<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('app'),
|
||||
input = ref(null),
|
||||
app = inject<Provided>('app'),
|
||||
input = ref<HTMLInputElement | null>(null),
|
||||
query = ref(''),
|
||||
collapsed = ref(false),
|
||||
emit = defineEmits(['select-dn', 'show-modal', 'show-oc']);
|
||||
emit = defineEmits(['select-dn', 'show-modal', 'show-oc', 'update:treeOpen']);
|
||||
|
||||
defineProps({
|
||||
baseDn: String,
|
||||
@ -52,6 +53,6 @@
|
||||
|
||||
function search() {
|
||||
query.value = '';
|
||||
nextTick(() => { query.value = input.value.value; });
|
||||
nextTick(() => { query.value = input?.value?.value || ''; });
|
||||
}
|
||||
</script>
|
||||
|
@ -6,14 +6,14 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
dn: String,
|
||||
oc: String,
|
||||
}),
|
||||
|
||||
icons = { // OC -> icon mapping
|
||||
icons : {[key: string]: string} = { // OC -> icon mapping
|
||||
account: 'user',
|
||||
groupOfNames: 'users',
|
||||
groupOfURLs: 'users',
|
||||
@ -30,7 +30,8 @@
|
||||
},
|
||||
|
||||
icon = computed(() => // Get the icon for an OC
|
||||
' fa-' + icons[props.oc] || 'question'),
|
||||
props.oc ? ' fa-' + (icons[props.oc] || 'question')
|
||||
: 'fa-question'),
|
||||
|
||||
// Shorten a DN for readability
|
||||
label = computed(() => (props.dn || '').split(',')[0]
|
||||
|
9
src/components/Provided.ts
Normal file
9
src/components/Provided.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { Options } from '../request';
|
||||
import type { LdapSchema } from './schema/schema';
|
||||
|
||||
export interface Provided {
|
||||
readonly schema?: LdapSchema;
|
||||
showInfo: (msg: string) => void;
|
||||
showWarning: (msg: string) => void;
|
||||
xhr: (options: Options) => Promise<unknown>;
|
||||
}
|
@ -1,72 +1,83 @@
|
||||
<template>
|
||||
<popover :open="show" @update:open="results = []" @select="done(results[$event].dn)">
|
||||
<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] }}
|
||||
{{ item[label as keyof Result] }}
|
||||
</li>
|
||||
</popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue';
|
||||
import Popover from './ui/Popover.vue';
|
||||
import type { Provided } from './Provided';
|
||||
|
||||
interface Result {
|
||||
dn: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
query: String,
|
||||
for: String,
|
||||
label: {
|
||||
type: String,
|
||||
default: 'name',
|
||||
validator: value => ['name', 'dn' ].includes(value)
|
||||
},
|
||||
shorten: String,
|
||||
silent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
}),
|
||||
app = inject('app'),
|
||||
results = ref([]),
|
||||
show = computed(() => props.query.trim() != ''
|
||||
&& results.value && results.value.length > 1),
|
||||
emit = defineEmits(['select-dn']);
|
||||
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,
|
||||
},
|
||||
}),
|
||||
|
||||
watch(() => props.query,
|
||||
async (q) => {
|
||||
if (!q) return;
|
||||
app = inject<Provided>('app'),
|
||||
results = ref<Result[]>([]),
|
||||
show = computed(() => props.query.trim() != ''
|
||||
&& results.value && results.value.length > 1),
|
||||
emit = defineEmits(['select-dn']);
|
||||
|
||||
results.value = await app.xhr({ url: 'api/search/' + q });
|
||||
if (!results.value) return; // app.xhr failed
|
||||
watch(() => props.query, async (q) => {
|
||||
if (!q) return;
|
||||
|
||||
if (results.value.length == 0 && !props.silent) {
|
||||
app.showWarning('No search results');
|
||||
return;
|
||||
}
|
||||
results.value = await app?.xhr({ url: 'api/search/' + q }) as Result[];
|
||||
if (!results.value) return; // app.xhr failed
|
||||
|
||||
if (results.value.length == 1) {
|
||||
done(results.value[0].dn);
|
||||
return;
|
||||
}
|
||||
if (results.value.length == 0 && !props.silent) {
|
||||
app?.showWarning('No search results');
|
||||
return;
|
||||
}
|
||||
|
||||
results.value.sort((a, b) =>
|
||||
a[props.label].toLowerCase().localeCompare(
|
||||
b[props.label].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) {
|
||||
function trim(dn: string) {
|
||||
return props.shorten && props.shorten != dn
|
||||
? dn.replace(props.shorten, '…') : dn;
|
||||
}
|
||||
|
||||
// use an auto-completion choice
|
||||
function done(dn) {
|
||||
function done(dn: string) {
|
||||
emit('select-dn', dn);
|
||||
results.value = [];
|
||||
|
||||
nextTick(()=> {
|
||||
// Return focus to search input
|
||||
const el = document.getElementById(props.for);
|
||||
if (el) el.focus();
|
||||
if (props.for) {
|
||||
const el = document.getElementById(props.for);
|
||||
if (el) el.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
6
src/components/TreeNode.ts
Normal file
6
src/components/TreeNode.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface TreeNode {
|
||||
dn: string;
|
||||
level?: number;
|
||||
hasSubordinates: boolean;
|
||||
structuralObjectClass: string;
|
||||
}
|
@ -3,7 +3,7 @@
|
||||
<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-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>
|
||||
@ -19,21 +19,33 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { DN } from './schema/schema';
|
||||
import { inject, onMounted, ref, watch } from 'vue';
|
||||
import NodeLabel from './NodeLabel.vue';
|
||||
import type { Provided } from './Provided';
|
||||
import type { TreeNode } from './TreeNode';
|
||||
|
||||
function Node(json) {
|
||||
Object.assign(this, json);
|
||||
this.level = this.dn.split(',').length;
|
||||
if (this.hasSubordinates) {
|
||||
this.subordinates = [];
|
||||
this.open = false;
|
||||
class Node implements TreeNode {
|
||||
dn: string;
|
||||
level: number | undefined;
|
||||
hasSubordinates: boolean;
|
||||
structuralObjectClass: string;
|
||||
open: boolean = false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Node.prototype = {
|
||||
find: function(dn) {
|
||||
find(dn: string): Node | undefined {
|
||||
// Primitive recursive search for a DN.
|
||||
// Compares DNs a strings, without any regard for
|
||||
// distinguishedNameMatch rules.
|
||||
@ -45,41 +57,41 @@
|
||||
return this.subordinates
|
||||
.map(node => node.find(dn))
|
||||
.filter(node => node)[0];
|
||||
},
|
||||
}
|
||||
|
||||
get loaded() {
|
||||
get loaded(): boolean {
|
||||
return !this.hasSubordinates || this.subordinates.length > 0;
|
||||
},
|
||||
}
|
||||
|
||||
parentDns: function(baseDn) {
|
||||
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);
|
||||
dn = dn.substring(idx + 1);
|
||||
}
|
||||
return dns;
|
||||
},
|
||||
}
|
||||
|
||||
visible: function() {
|
||||
visible(): Node[] {
|
||||
if (!this.hasSubordinates || !this.open) return [this];
|
||||
return [this].concat(
|
||||
return [this as Node].concat(
|
||||
this.subordinates.flatMap(
|
||||
node => node.visible()));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
activeDn: String,
|
||||
}),
|
||||
app = inject('app'),
|
||||
tree = ref(null),
|
||||
app = inject<Provided>('app'),
|
||||
tree = ref<Node>(),
|
||||
emit = defineEmits(['base-dn', 'update:activeDn']);
|
||||
|
||||
onMounted(async () => {
|
||||
await reload('base');
|
||||
emit('base-dn', tree.value.dn);
|
||||
emit('base-dn', tree.value?.dn);
|
||||
});
|
||||
|
||||
watch(() => props.activeDn, async (selected) => {
|
||||
@ -92,40 +104,40 @@
|
||||
}
|
||||
|
||||
// Get all parents of the selected entry in the tree
|
||||
const dn = new app.schema.DN(selected || tree.value.dn);
|
||||
let hierarchy = [];
|
||||
for (let node = dn; node; node = node.parent) {
|
||||
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 == tree.value.dn) break;
|
||||
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);
|
||||
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;
|
||||
if (!tree.value?.find(dn.toString())) {
|
||||
await reload(dn.parent!.toString());
|
||||
tree.value!.find(dn.parent!.toString())!.open = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function clicked(dn) {
|
||||
const item = tree.value.find(dn);
|
||||
if (item.hasSubordinates && !item.open) await toggle(item);
|
||||
async function clicked(dn: string) {
|
||||
const item = tree.value?.find(dn);
|
||||
if (item && item.hasSubordinates && !item.open) await toggle(item);
|
||||
emit('update:activeDn', dn);
|
||||
}
|
||||
|
||||
// Reload the subtree at entry with given DN
|
||||
async function reload(dn) {
|
||||
const response = await app.xhr({ url: 'api/tree/' + dn }) || [];
|
||||
response.sort((a, b) => a.dn.toLowerCase().localeCompare(b.dn.toLowerCase()));
|
||||
async function reload(dn: string) {
|
||||
const response = await app?.xhr({ url: 'api/tree/' + dn }) as Node[] || [];
|
||||
response.sort((a: Node, b: Node) => a.dn.toLowerCase().localeCompare(b.dn.toLowerCase()));
|
||||
|
||||
if (dn == 'base') {
|
||||
tree.value = new Node(response[0]);
|
||||
@ -133,14 +145,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const item = tree.value.find(dn);
|
||||
item.subordinates = response.map(node => new Node(node));
|
||||
item.hasSubordinates = item.subordinates.length > 0;
|
||||
const item = tree.value?.find(dn);
|
||||
if (item) {
|
||||
item.subordinates = response.map(node => new Node(node));
|
||||
item.hasSubordinates = item.subordinates.length > 0;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Hide / show tree elements
|
||||
async function toggle(item) {
|
||||
async function toggle(item: Node) {
|
||||
if (!item.open && !item.loaded) await reload(item.dn);
|
||||
item.open = !item.open;
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<modal title="Add attribute" :open="modal == 'add-attribute'" :return-to="props.returnTo"
|
||||
@show="attr = null;" @shown="select.focus()"
|
||||
@show="attr = undefined;" @shown="select?.focus()"
|
||||
@ok="onOk" @cancel="emit('update:modal')">
|
||||
|
||||
<select v-model="attr" ref="select" @keyup.enter="onOk">
|
||||
@ -9,18 +9,18 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
attributes: { type: Array, required: true },
|
||||
attributes: { type: Array<string>, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
}),
|
||||
attr = ref(null),
|
||||
select = ref(null),
|
||||
attr = ref<string>(),
|
||||
select = ref<HTMLSelectElement | null>(null),
|
||||
available = computed(() => {
|
||||
// Choice list for new attribute selection popup
|
||||
const attrs = Object.keys(props.entry.attrs);
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<modal title="Add objectClass" :open="modal == 'add-object-class'"
|
||||
@show="oc = null;" @shown="select.focus()"
|
||||
@show="oc = undefined;" @shown="select?.focus()"
|
||||
@ok="onOk" @cancel="emit('update:modal')">
|
||||
|
||||
<select v-model="oc" ref="select" @keyup.enter="onOk">
|
||||
@ -9,7 +9,7 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
@ -17,11 +17,11 @@
|
||||
entry: { type: Object, required: true },
|
||||
modal: String,
|
||||
}),
|
||||
oc = ref(null),
|
||||
select = ref(null),
|
||||
available = computed(() => {
|
||||
oc = ref<string>(),
|
||||
select = ref<HTMLSelectElement | null>(),
|
||||
available = computed<string[]>(() => {
|
||||
const classes = props.entry.attrs.objectClass;
|
||||
return props.entry.meta.aux.filter(cls => !classes.includes(cls));
|
||||
return props.entry.meta.aux.filter((cls: string) => !classes.includes(cls));
|
||||
}),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
|
@ -1,42 +1,44 @@
|
||||
<template>
|
||||
<modal title="Upload photo" hide-footer :return-to="returnTo"
|
||||
:open="modal == 'add-' + attr"
|
||||
@shown="upload.focus()" @cancel="emit('update:modal')">
|
||||
@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>
|
||||
<script setup lang="ts">
|
||||
import { ref, inject } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import type { Provided } from "../Provided";
|
||||
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
attr: {
|
||||
type: String,
|
||||
validator: value => ['jpegPhoto', 'thumbnailPhoto'].includes(value),
|
||||
validator: (value: string) => ['jpegPhoto', 'thumbnailPhoto'].includes(value),
|
||||
},
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
}),
|
||||
app = inject('app'),
|
||||
upload = ref('upload'),
|
||||
app = inject<Provided>('app'),
|
||||
upload = ref<HTMLInputElement | null>(null),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
// add an image
|
||||
async function onOk(evt) {
|
||||
if (!evt.target.files) return;
|
||||
async function onOk(evt: Event) {
|
||||
const target = evt.target as HTMLInputElement;
|
||||
if (!target?.files) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('blob', evt.target.files[0])
|
||||
const data = await app.xhr({
|
||||
fd.append('blob', target.files[0])
|
||||
const data = await app?.xhr({
|
||||
url: 'api/blob/' + props.attr + '/0/' + props.dn,
|
||||
method: 'PUT',
|
||||
data: fd,
|
||||
binary: true,
|
||||
});
|
||||
}) as { changed: string[] };
|
||||
|
||||
if (data) {
|
||||
emit('update:modal');
|
||||
|
@ -15,15 +15,15 @@
|
||||
class="remove-btn control" :title="'Remove ' + val">⊖</span>
|
||||
<span v-else-if="password" class="fa fa-question-circle control"
|
||||
@click="emit('show-modal', 'change-password')" tabindex="-1" title="change password"></span>
|
||||
<span v-else-if="attr == 'jpegPhoto' || attr == 'thumbnailPhoto'"
|
||||
<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 == 'jpegPhoto' || attr == 'thumbnailPhoto'">
|
||||
<img v-if="val" :src="'data:image/' + ((attr == 'jpegPhoto') ? 'jpeg' : '*') +';base64,' + val"
|
||||
<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>
|
||||
@ -42,7 +42,7 @@
|
||||
:placeholder="placeholder" :disabled="disabled" :title="time ? dateString(val) : ''"
|
||||
@input="update" @focusin="query = ''" @keyup="search" @keyup.esc="query = ''" />
|
||||
|
||||
<i v-if="attr == 'objectClass'" class="cursor-pointer fa fa-info-circle"
|
||||
<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"
|
||||
@ -53,117 +53,123 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<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 './ToggleButton.vue';
|
||||
import ToggleButton from '../ui/ToggleButton.vue';
|
||||
|
||||
function unique(element, index, array) {
|
||||
function unique(element: unknown, index: number, array: Array<unknown>): boolean {
|
||||
return element == '' || array.indexOf(element) == index;
|
||||
}
|
||||
|
||||
const dateFormat = {
|
||||
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
|
||||
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: Object, required: true },
|
||||
baseDn: String,
|
||||
values: { type: Array, required: true },
|
||||
meta: { type: Object, required: true },
|
||||
must: { type: Boolean, required: true },
|
||||
may: { type: Boolean, required: true },
|
||||
changed: { type: Boolean, required: true },
|
||||
}),
|
||||
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('app'),
|
||||
app = inject<Provided>('app'),
|
||||
|
||||
valid = ref(true),
|
||||
valid = ref(true),
|
||||
|
||||
// Range auto-completion
|
||||
autoFilled = ref(null),
|
||||
hint = ref(''),
|
||||
// Range auto-completion
|
||||
autoFilled = ref<string>(),
|
||||
hint = ref(''),
|
||||
|
||||
// DN search
|
||||
query = ref(''),
|
||||
elementId = ref(null),
|
||||
// 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),
|
||||
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(() =>
|
||||
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))),
|
||||
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;
|
||||
}),
|
||||
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)),
|
||||
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';
|
||||
}),
|
||||
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']);
|
||||
emit = defineEmits(['reload-form', 'show-attr', 'show-modal', 'show-oc', 'update', 'valid']);
|
||||
|
||||
watch(valid, (ok) => emit('valid', ok));
|
||||
|
||||
onMounted(async () => {
|
||||
// Auto-fill ranges
|
||||
if (disabled.value
|
||||
|| !idRanges.includes(props.attr.name)
|
||||
|| !idRanges.includes(props.attr.name!)
|
||||
|| props.values.length != 1
|
||||
|| props.values[0]) return;
|
||||
|
||||
const range = await app.xhr({ url: 'api/range/' + props.attr.name });
|
||||
const range = await app?.xhr({ url: 'api/range/' + props.attr.name }) as {
|
||||
min: number;
|
||||
max: number;
|
||||
next: number;
|
||||
};
|
||||
if (!range) return;
|
||||
|
||||
hint.value = range.min == range.max
|
||||
? '> ' + range.min
|
||||
: '\u2209 (' + range.min + " - " + range.max + ')';
|
||||
autoFilled.value = new String(range.next);
|
||||
autoFilled.value = '' + range.next;
|
||||
emit('update', props.attr.name, [autoFilled.value], 0);
|
||||
validate();
|
||||
});
|
||||
@ -176,61 +182,53 @@
|
||||
&& props.values.every(unique);
|
||||
}
|
||||
|
||||
function update(evt) {
|
||||
const value = evt.target.value,
|
||||
index = +evt.target.id.split('-').slice(-1).pop();
|
||||
function update(evt: Event) {
|
||||
const target = evt.target as HTMLInputElement;
|
||||
const value = target.value,
|
||||
index = +target.id.split('-').slice(-1).pop()!;
|
||||
updateValue(index, value);
|
||||
}
|
||||
|
||||
function updateValue(index, value) {
|
||||
let values = props.values.slice();
|
||||
function updateValue(index: number, value: string) {
|
||||
const values = props.values.slice();
|
||||
values[index] = value;
|
||||
emit('update', props.attr.name, values);
|
||||
}
|
||||
|
||||
// Add an empty row in the entry form
|
||||
function addRow() {
|
||||
let values = props.values.slice();
|
||||
const values = props.values.slice();
|
||||
if (!values.includes('')) values.push('');
|
||||
emit('update', props.attr.name, values, values.length - 1);
|
||||
}
|
||||
|
||||
// Remove a row from the entry form
|
||||
function removeObjectClass(index) {
|
||||
let values = props.values.slice(0, index).concat(props.values.slice(index + 1));
|
||||
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) {
|
||||
let tz = dt.substr(14);
|
||||
if (tz != 'Z') {
|
||||
tz = tz.substr(0, 3) + ':'
|
||||
+ (tz.length > 3 ? tz.substring(3, 2) : '00');
|
||||
}
|
||||
return new Date(dt.substr(0, 4) + '-'
|
||||
+ dt.substr(4, 2) + '-'
|
||||
+ dt.substr(6, 2) + 'T'
|
||||
+ dt.substr(8, 2) + ':'
|
||||
+ dt.substr(10, 2) + ':'
|
||||
+ dt.substr(12, 2) + tz).toLocaleString(undefined, dateFormat);
|
||||
function dateString(dt: string) {
|
||||
return generalizedTime(dt).toLocaleString(undefined, dateFormat);
|
||||
}
|
||||
|
||||
// Is the given value a structural object class?
|
||||
function isStructural(val) {
|
||||
return props.attr.name == 'objectClass' && app.schema.oc(val).structural;
|
||||
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) {
|
||||
return props.attr.name == 'objectClass' && !app.schema.oc(val).structural;
|
||||
function isAux(val: string) {
|
||||
const oc = app?.schema?.oc(val);
|
||||
return props.attr.name == 'objectClass' && oc && !oc.structural;
|
||||
}
|
||||
|
||||
function duplicate(index) {
|
||||
function duplicate(index: number) {
|
||||
return !unique(props.values[index], index, props.values);
|
||||
}
|
||||
|
||||
function multiple(index) {
|
||||
function multiple(index: number) {
|
||||
return index == 0
|
||||
&& !props.attr.single_value
|
||||
&& !disabled.value
|
||||
@ -238,27 +236,28 @@
|
||||
}
|
||||
|
||||
// auto-complete form values
|
||||
function search(evt) {
|
||||
elementId.value = evt.target.id;
|
||||
const q = evt.target.value;
|
||||
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 : '';
|
||||
}
|
||||
|
||||
// use an auto-completion choice
|
||||
function complete(dn) {
|
||||
const index = +elementId.value.split('-').slice(-1).pop();
|
||||
let values = props.values.slice();
|
||||
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) {
|
||||
const data = await app.xhr({
|
||||
async function deleteBlob(index: number) {
|
||||
const data = await app?.xhr({
|
||||
method: 'DELETE',
|
||||
url: 'api/blob/' + props.attr.name + '/' + index + '/' + props.meta.dn,
|
||||
});
|
||||
}) as { changed: string[] };
|
||||
|
||||
if (data) emit('reload-form', props.meta.dn, data.changed);
|
||||
}
|
||||
|
@ -1,38 +1,40 @@
|
||||
<template>
|
||||
<popover :open="show" @update:open="results = []" @select="done(results[$event].name)">
|
||||
<li v-for="item in results" :key="item.oid" @click="done(item.name)"
|
||||
<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>
|
||||
<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';
|
||||
|
||||
const props = defineProps({
|
||||
query: String,
|
||||
for: String,
|
||||
query: { type: String, default: ''},
|
||||
for: { type: String, default: ''},
|
||||
}),
|
||||
app = inject('app'),
|
||||
results = ref([]),
|
||||
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])),
|
||||
&& !(results.value.length == 1 && props.query == results.value[0].name)),
|
||||
emit = defineEmits(['done']);
|
||||
|
||||
watch(() => props.query,
|
||||
(q) => {
|
||||
if (!q) return;
|
||||
results.value = app.schema.search(q);
|
||||
results.value.sort((a, b) =>
|
||||
a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
||||
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) {
|
||||
function done(value: string) {
|
||||
emit('done', value);
|
||||
results.value = [];
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<modal title="Copy entry" :open="modal == 'copy-entry'" :return-to="returnTo"
|
||||
@show="init" @shown="newdn.focus()"
|
||||
@show="init" @shown="newdn?.focus()"
|
||||
@ok="onOk" @cancel="emit('update:modal')">
|
||||
|
||||
<div>
|
||||
@ -10,7 +10,7 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
@ -19,9 +19,9 @@
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
}),
|
||||
dn = ref(null),
|
||||
dn = ref(''),
|
||||
error = ref(''),
|
||||
newdn = ref(null),
|
||||
newdn = ref<HTMLInputElement | null>(null),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
function init() {
|
||||
|
@ -5,7 +5,7 @@
|
||||
|
||||
<p class="strong">This action is irreversible.</p>
|
||||
|
||||
<div v-if="subtree && subtree.length">
|
||||
<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>
|
||||
@ -21,27 +21,29 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import NodeLabel from '../NodeLabel.vue';
|
||||
import type { Provided } from '../Provided';
|
||||
import type { TreeNode } from '../TreeNode';
|
||||
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
}),
|
||||
app = inject('app'),
|
||||
subtree = ref([]),
|
||||
app = inject<Provided>('app'),
|
||||
subtree = ref<TreeNode[]>([]),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
// List subordinate elements to be deleted
|
||||
async function init() {
|
||||
subtree.value = await app.xhr({ url: 'api/subtree/' + props.dn}) || [];
|
||||
subtree.value = await app?.xhr({ url: 'api/subtree/' + props.dn}) as TreeNode[] || [];
|
||||
}
|
||||
|
||||
function onShown() {
|
||||
document.getElementById('ui-modal-ok').focus();
|
||||
document.getElementById('ui-modal-ok')?.focus();
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
|
@ -11,7 +11,7 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
@ -22,11 +22,11 @@
|
||||
});
|
||||
|
||||
const
|
||||
next = ref(null),
|
||||
next = ref<string>(),
|
||||
emit = defineEmits(['ok', 'shown', 'update:modal']);
|
||||
|
||||
function onShown() {
|
||||
document.getElementById('ui-modal-ok').focus();
|
||||
document.getElementById('ui-modal-ok')?.focus();
|
||||
emit('shown');
|
||||
}
|
||||
|
||||
|
13
src/components/editor/Entry.ts
Normal file
13
src/components/editor/Entry.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export interface Entry {
|
||||
attrs: { [key: string]: string[] };
|
||||
meta: {
|
||||
autoFilled: string[];
|
||||
aux: string[];
|
||||
binary: string[];
|
||||
dn: string;
|
||||
// hints: object;
|
||||
isNew?: boolean;
|
||||
required: string[];
|
||||
}
|
||||
changed?: string[];
|
||||
}
|
@ -50,10 +50,10 @@
|
||||
</nav>
|
||||
|
||||
<form id="entry" class="space-y-4 my-4" @submit.prevent="save"
|
||||
@reset="load(entry.meta.dn)" @focusin="onFocus">
|
||||
@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="entry.changed.includes(key)"
|
||||
:attr="app?.schema?.attr(key)!" :meta="entry.meta" :values="entry.attrs[key]"
|
||||
:changed="entry.changed?.includes(key) || false"
|
||||
:may="attributes('may').includes(key)" :must="attributes('must').includes(key)"
|
||||
@update="updateRow"
|
||||
@reload-form="load"
|
||||
@ -82,7 +82,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue';
|
||||
import AddAttributeDialog from './AddAttributeDialog.vue';
|
||||
import AddObjectClassDialog from './AddObjectClassDialog.vue';
|
||||
@ -92,13 +92,15 @@
|
||||
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 { request } from '../../request.js';
|
||||
import { request } from '../../request';
|
||||
|
||||
function unique(element, index, array) {
|
||||
function unique(element: unknown, index: number, array: Array<unknown>): boolean {
|
||||
return array.indexOf(element) == index;
|
||||
}
|
||||
|
||||
@ -110,21 +112,21 @@
|
||||
user: String,
|
||||
}),
|
||||
|
||||
app = inject('app'),
|
||||
entry = ref(null), // entry in editor
|
||||
focused = ref(null), // currently focused input
|
||||
invalid = ref([]), // field IDs with validation errors
|
||||
modal = ref(null), // pop-up dialog
|
||||
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
|
||||
|
||||
keys = computed(() => {
|
||||
let keys = Object.keys(entry.value.attrs);
|
||||
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))
|
||||
const oc = entry.value?.attrs.objectClass
|
||||
.map(oc => app?.schema?.oc(oc as string))
|
||||
.filter(oc => oc && oc.structural)[0];
|
||||
return oc ? oc.name : '';
|
||||
}),
|
||||
@ -132,19 +134,19 @@
|
||||
emit = defineEmits(['update:activeDn', 'show-attr', 'show-oc']);
|
||||
|
||||
watch(() => props.activeDn, (dn) => {
|
||||
if (!entry.value || dn != entry.value.meta.dn) focused.value = undefined;
|
||||
if (!entry.value || dn != entry.value!.meta.dn) focused.value = undefined;
|
||||
|
||||
if (dn && entry.value && entry.value.meta.isNew) {
|
||||
if (dn && entry.value && entry.value!.meta.isNew) {
|
||||
modal.value = 'discard-entry';
|
||||
}
|
||||
else if (dn) load(dn);
|
||||
else if (entry.value && !entry.value.meta.isNew) entry.value = null;
|
||||
else if (dn) load(dn, undefined, undefined);
|
||||
else if (entry.value && !entry.value!.meta.isNew) entry.value = undefined;
|
||||
});
|
||||
|
||||
function focus(focused) {
|
||||
function focus(focused: string | undefined) {
|
||||
nextTick(() => {
|
||||
const input = focused ? document.getElementById(focused)
|
||||
: document.querySelector('form#entry input:not([disabled]), form#entry button[type="button"]');
|
||||
: 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
|
||||
@ -154,46 +156,46 @@
|
||||
}
|
||||
|
||||
// Track focus changes
|
||||
function onFocus(evt) {
|
||||
const el = evt.target;
|
||||
function onFocus(evt: FocusEvent) {
|
||||
const el = evt.target as HTMLElement;
|
||||
if (el.id && inputTags.includes(el.tagName)) focused.value = el.id;
|
||||
}
|
||||
|
||||
function newEntry(newEntry) {
|
||||
function newEntry(newEntry: Entry) {
|
||||
entry.value = newEntry;
|
||||
emit('update:activeDn');
|
||||
focus(addMandatoryRows());
|
||||
}
|
||||
|
||||
function discardEntry(dn) {
|
||||
entry.value = null;
|
||||
function discardEntry(dn: string) {
|
||||
entry.value = undefined;
|
||||
emit('update:activeDn', dn);
|
||||
}
|
||||
|
||||
function addAttribute(attr) {
|
||||
entry.value.attrs[attr] = [''];
|
||||
function addAttribute(attr: string) {
|
||||
entry.value!.attrs[attr] = [''];
|
||||
focus(attr + '-0');
|
||||
}
|
||||
|
||||
function addObjectClass(oc) {
|
||||
entry.value.attrs.objectClass.push(oc);
|
||||
const aux = entry.value.meta.aux.filter(oc => oc < oc);
|
||||
entry.value.meta.aux.splice(aux.length, 1);
|
||||
function addObjectClass(oc: string) {
|
||||
entry.value!.attrs.objectClass.push(oc);
|
||||
const aux = entry.value!.meta.aux.filter(oc => oc < oc);
|
||||
entry.value!.meta.aux.splice(aux.length, 1);
|
||||
focus(addMandatoryRows() || focused.value);
|
||||
}
|
||||
|
||||
// Remove a row from the entry form
|
||||
function removeObjectClass(newOcs) {
|
||||
const removedOc = entry.value.attrs.objectClass.filter(
|
||||
function removeObjectClass(newOcs: string[]) {
|
||||
const removedOc = entry.value!.attrs.objectClass.filter(
|
||||
oc => !newOcs.includes(oc))[0];
|
||||
if (removedOc) {
|
||||
const aux = entry.value.meta.aux.filter(oc => oc < removedOc);
|
||||
entry.value.meta.aux.splice(aux.length, 0, removedOc);
|
||||
const aux = entry.value!.meta.aux.filter(oc => oc < removedOc);
|
||||
entry.value!.meta.aux.splice(aux.length, 0, removedOc);
|
||||
}
|
||||
}
|
||||
|
||||
function updateRow(attr, values, index) {
|
||||
entry.value.attrs[attr] = values;
|
||||
function updateRow(attr: string, values: string[], index: number) {
|
||||
entry.value!.attrs[attr] = values;
|
||||
if (attr == 'objectClass') {
|
||||
removeObjectClass(values);
|
||||
focus(focused.value);
|
||||
@ -201,27 +203,27 @@
|
||||
if (index !== undefined) focus(attr + '-' + index);
|
||||
}
|
||||
|
||||
function addMandatoryRows() {
|
||||
function addMandatoryRows() : string | undefined {
|
||||
const must = attributes('must')
|
||||
.filter(attr => !entry.value.attrs[attr]);
|
||||
must.forEach(attr => entry.value.attrs[attr] = ['']);
|
||||
.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, changed, focused) {
|
||||
async function load(dn: string, changed: string[] | undefined, focused: string | undefined) {
|
||||
invalid.value = [];
|
||||
|
||||
if (!dn || dn.startsWith('-')) {
|
||||
entry.value = null;
|
||||
entry.value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
entry.value = await app.xhr({ url: 'api/entry/' + dn });
|
||||
entry.value = await app?.xhr({ url: 'api/entry/' + dn }) as Entry;
|
||||
if (!entry.value) return;
|
||||
|
||||
entry.value.changed = changed || [];
|
||||
entry.value.meta.isNew = false;
|
||||
entry.value!.changed = changed || [];
|
||||
entry.value!.meta.isNew = false;
|
||||
|
||||
document.title = dn.split(',')[0];
|
||||
focus(focused);
|
||||
@ -234,86 +236,86 @@
|
||||
return;
|
||||
}
|
||||
|
||||
entry.value.changed = [];
|
||||
const data = await app.xhr({
|
||||
url: 'api/entry/' + entry.value.meta.dn,
|
||||
method: entry.value.meta.isNew ? 'PUT' : 'POST',
|
||||
data: JSON.stringify(entry.value.attrs),
|
||||
});
|
||||
entry.value!.changed = [];
|
||||
const data = await app?.xhr({
|
||||
url: 'api/entry/' + entry.value!.meta.dn,
|
||||
method: entry.value!.meta.isNew ? 'PUT' : 'POST',
|
||||
data: JSON.stringify(entry.value!.attrs),
|
||||
}) as { changed: string[] };
|
||||
|
||||
if (!data) return;
|
||||
|
||||
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);
|
||||
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);
|
||||
else load(entry.value!.meta.dn, data.changed, focused.value);
|
||||
}
|
||||
|
||||
async function renameEntry(rdn) {
|
||||
await app.xhr({
|
||||
async function renameEntry(rdn: string) {
|
||||
await app?.xhr({
|
||||
url: 'api/rename',
|
||||
method: 'POST',
|
||||
data: JSON.stringify({
|
||||
dn: entry.value.meta.dn,
|
||||
dn: entry.value!.meta.dn,
|
||||
rdn: rdn
|
||||
}),
|
||||
});
|
||||
|
||||
const dnparts = entry.value.meta.dn.split(',');
|
||||
const dnparts = entry.value!.meta.dn.split(',');
|
||||
dnparts.splice(0, 1, rdn);
|
||||
emit('update:activeDn', dnparts.join(','));
|
||||
}
|
||||
|
||||
async function deleteEntry(dn) {
|
||||
if (await app.xhr({ url: 'api/entry/' + dn, method: 'DELETE' }) !== undefined) {
|
||||
app.showInfo('Deleted: ' + dn);
|
||||
async function deleteEntry(dn: string) {
|
||||
if (await app?.xhr({ url: 'api/entry/' + dn, method: 'DELETE' }) !== undefined) {
|
||||
app?.showInfo('Deleted: ' + dn);
|
||||
emit('update:activeDn', '-' + dn);
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(oldPass, newPass) {
|
||||
const data = await app.xhr({
|
||||
url: 'api/entry/password/' + entry.value.meta.dn,
|
||||
async function changePassword(oldPass: string, newPass: string) {
|
||||
const data = await app?.xhr({
|
||||
url: 'api/entry/password/' + entry.value!.meta.dn,
|
||||
method: 'POST',
|
||||
data: JSON.stringify({ old: oldPass, new1: newPass }),
|
||||
});
|
||||
}) 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 data = await request({
|
||||
url: 'api/ldif/' + entry.value.meta.dn,
|
||||
url: 'api/ldif/' + entry.value!.meta.dn,
|
||||
responseType: 'blob' });
|
||||
if (!data) return;
|
||||
|
||||
const a = document.createElement("a"),
|
||||
url = URL.createObjectURL(data.response);
|
||||
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) {
|
||||
let attrs = entry.value.attrs.objectClass
|
||||
function attributes(kind : 'must' | 'may') {
|
||||
const attrs = entry.value!.attrs.objectClass
|
||||
.filter(oc => oc && oc != 'top')
|
||||
.map(oc => app.schema.oc(oc))
|
||||
.map(oc => app?.schema?.oc(oc))
|
||||
.flatMap(oc => oc ? oc.$collect(kind): [])
|
||||
.filter(unique);
|
||||
attrs.sort();
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function valid(key, valid) {
|
||||
function valid(key: string, valid: boolean) {
|
||||
if (valid) {
|
||||
const pos = invalid.value.indexOf(key);
|
||||
if (pos >= 0) invalid.value.splice(pos, 1);
|
||||
|
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<modal title="New entry" :open="modal == 'new-entry'" :return-to="returnTo"
|
||||
@ok="onOk" @cancel="emit('update:modal')"
|
||||
@show="init" @shown="select.focus()">
|
||||
@show="init" @shown="select?.focus()">
|
||||
|
||||
<label>Object class:
|
||||
<select ref="select" v-model="objectClass">
|
||||
<template v-for="cls in app.schema.ObjectClass.values" :key="cls.name">
|
||||
<template v-for="cls in app?.schema?.objectClasses.values()" :key="cls.name">
|
||||
<option v-if="cls.structural">{{ cls }}</option>
|
||||
</template>
|
||||
</select>
|
||||
@ -24,25 +24,27 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<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';
|
||||
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
modal: String,
|
||||
returnTo: String,
|
||||
}),
|
||||
app = inject('app'),
|
||||
objectClass = ref(null),
|
||||
rdn = ref(null),
|
||||
name = ref(null),
|
||||
select = ref(null),
|
||||
oc = computed(() => app.schema.oc(objectClass.value)),
|
||||
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']);
|
||||
|
||||
function init() {
|
||||
objectClass.value = rdn.value = name.value = null;
|
||||
objectClass.value = rdn.value = name.value = '';
|
||||
}
|
||||
|
||||
// Create a new entry in the main editor
|
||||
@ -52,19 +54,19 @@
|
||||
emit('update:modal');
|
||||
|
||||
const objectClasses = [objectClass.value];
|
||||
for (let o = oc.value.$super; o; o = o.$super) {
|
||||
for (let o = oc.value?.$super; o; o = o.$super) {
|
||||
if (!o.structural && o.kind != 'abstract') {
|
||||
objectClasses.push(o.name);
|
||||
objectClasses.push(o.name!);
|
||||
}
|
||||
}
|
||||
|
||||
const entry = {
|
||||
const entry: Entry = {
|
||||
meta: {
|
||||
dn: rdn.value + '=' + name.value + ',' + props.dn,
|
||||
aux: [],
|
||||
required: [],
|
||||
binary: [],
|
||||
hints: {},
|
||||
// hints: {},
|
||||
autoFilled: [],
|
||||
isNew: true,
|
||||
},
|
||||
@ -80,7 +82,7 @@
|
||||
// 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;
|
||||
}
|
||||
|
@ -19,9 +19,10 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
import type { Provided } from '../Provided';
|
||||
|
||||
const props = defineProps({
|
||||
entry: { type: Object, required: true },
|
||||
@ -30,18 +31,18 @@
|
||||
user: String,
|
||||
}),
|
||||
|
||||
app = inject('app'),
|
||||
app = inject<Provided>('app'),
|
||||
oldPassword = ref(''),
|
||||
newPassword = ref(''),
|
||||
repeated = ref(''),
|
||||
passwordOk = ref(),
|
||||
passwordOk = ref<boolean>(),
|
||||
|
||||
old = ref(null),
|
||||
changed = ref(null),
|
||||
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
|
||||
oldExists = computed(() => !!props.entry.attrs.userPassword
|
||||
&& props.entry.attrs.userPassword[0] != ''),
|
||||
|
||||
emit = defineEmits(['ok', 'update-form', 'update:modal']);
|
||||
@ -52,8 +53,8 @@
|
||||
}
|
||||
|
||||
function focus() {
|
||||
if (oldExists.value) old.value.focus();
|
||||
else changed.value.focus();
|
||||
if (oldExists.value) old.value?.focus();
|
||||
else changed.value?.focus();
|
||||
}
|
||||
|
||||
// Verify an existing password
|
||||
@ -64,11 +65,11 @@
|
||||
passwordOk.value = undefined;
|
||||
return;
|
||||
}
|
||||
passwordOk.value = await app.xhr({
|
||||
passwordOk.value = await app?.xhr({
|
||||
url: 'api/entry/password/' + props.entry.meta.dn,
|
||||
method: 'POST',
|
||||
data: JSON.stringify({ check: oldPassword.value }),
|
||||
});
|
||||
}) as boolean;
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modal title="Rename entry" :open="modal == 'rename-entry'" :return-to="returnTo"
|
||||
@ok="onOk" @cancel="emit('update:modal')"
|
||||
@show="init" @shown="select.focus()">
|
||||
@show="init" @shown="select?.focus()">
|
||||
|
||||
<label>New RDN attribute:
|
||||
<select ref="select" v-model="rdn" @keyup.enter="onOk">
|
||||
@ -11,7 +11,7 @@
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../ui/Modal.vue';
|
||||
|
||||
@ -21,8 +21,8 @@
|
||||
returnTo: String,
|
||||
}),
|
||||
|
||||
rdn = ref(),
|
||||
select = ref(null),
|
||||
rdn = ref<string>(),
|
||||
select = ref<HTMLInputElement | null>(null),
|
||||
rdns = computed(() => Object.keys(props.entry.attrs).filter(ok)),
|
||||
emit = defineEmits(['ok', 'update:modal']);
|
||||
|
||||
@ -31,15 +31,15 @@
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
const rdnAttr = props.entry.attrs[rdn.value];
|
||||
const rdnAttr = props.entry.attrs[rdn.value || ''];
|
||||
if (rdnAttr && rdnAttr[0]) {
|
||||
emit('update:modal');
|
||||
emit('ok', rdn.value + '=' + rdnAttr[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function ok(key) {
|
||||
function ok(key: string) {
|
||||
const rdn = props.entry.meta.dn.split('=')[0];
|
||||
return key != rdn && !props.entry.attrs[key].every(val => !val);
|
||||
return key != rdn && !props.entry.attrs[key].every((val: unknown) => !val);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<card v-if="modelValue" :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>
|
||||
|
||||
@ -16,12 +16,13 @@
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
import Card from '../ui/Card.vue';
|
||||
import type { Provided } from '../Provided';
|
||||
|
||||
const props = defineProps({ modelValue: String }),
|
||||
app = inject('app'),
|
||||
attr = computed(() => app.schema.attr(props.modelValue)),
|
||||
app = inject<Provided>('app'),
|
||||
attr = computed(() => app?.schema?.attr(props.modelValue)),
|
||||
emit = defineEmits(['show-attr', 'update:modelValue']);
|
||||
</script>
|
||||
|
@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<card v-if="modelValue" :title="oc.name" class="ml-4" @close="emit('update:modelValue')">
|
||||
<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>
|
||||
<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>
|
||||
@ -29,12 +29,13 @@
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
import Card from '../ui/Card.vue';
|
||||
import type { Provided } from '../Provided';
|
||||
|
||||
const props = defineProps({ modelValue: String }),
|
||||
app = inject('app'),
|
||||
oc = computed(() => app.schema.oc(props.modelValue)),
|
||||
app = inject<Provided>('app'),
|
||||
oc = computed(() => app?.schema?.oc(props.modelValue)),
|
||||
emit = defineEmits(['show-attr', 'show-oc', 'update:modelValue']);
|
||||
</script>
|
||||
|
@ -1,169 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
export function LdapSchema(json) {
|
||||
|
||||
function unique(element, index, array) {
|
||||
return array.indexOf(element) == index;
|
||||
}
|
||||
|
||||
function RDN(value) {
|
||||
this.text = value;
|
||||
const parts = value.split('=');
|
||||
this.attrName = parts[0].trim();
|
||||
this.value = parts[1].trim();
|
||||
}
|
||||
|
||||
RDN.prototype = {
|
||||
toString: function() { return this.text; },
|
||||
|
||||
eq: function(other) {
|
||||
return other
|
||||
&& this.attr.eq(other.attr)
|
||||
&& this.attr.matcher(this.value, other.value);
|
||||
},
|
||||
|
||||
get attr() {
|
||||
return this.$attributes.$get(this.attrName);
|
||||
},
|
||||
};
|
||||
|
||||
function DN(value) {
|
||||
this.text = value;
|
||||
const parts = value.split(',');
|
||||
this.rdn = new RDN(parts[0]);
|
||||
this.parent = parts.length == 1 ? undefined
|
||||
: new DN(value.slice(parts[0].length + 1));
|
||||
}
|
||||
|
||||
DN.prototype = {
|
||||
toString: function() { return this.text; },
|
||||
|
||||
eq: function(other) {
|
||||
if (!other || !this.rdn.eq(other.rdn)) return false;
|
||||
if (!this.parent && !other.parent) return true;
|
||||
return this.parent && this.parent.eq(other.parent);
|
||||
},
|
||||
};
|
||||
|
||||
function ObjectClass(json) {
|
||||
Object.assign(this, json);
|
||||
}
|
||||
|
||||
ObjectClass.prototype = {
|
||||
get structural() { return this.kind == 'structural'; },
|
||||
|
||||
// gather values from a field across all superclasses
|
||||
$collect: function(name) {
|
||||
let attributes = [];
|
||||
for (let oc = this; oc; oc = oc.$super) {
|
||||
const attrs = oc[name];
|
||||
if (attrs) attributes.push(attrs);
|
||||
}
|
||||
|
||||
const result = attributes.flat()
|
||||
.map(attr => this.$attributes.$get(attr).name)
|
||||
.filter(unique);
|
||||
result.sort();
|
||||
return result;
|
||||
},
|
||||
|
||||
toString: function() { return this.names[0]; },
|
||||
|
||||
get $super() {
|
||||
const parent = Object.getPrototypeOf(this);
|
||||
return parent.sup ? parent : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
function Attribute(json) {
|
||||
Object.getOwnPropertyNames(json)
|
||||
.forEach(prop => {
|
||||
const value = json[prop];
|
||||
if (value !== null) this[prop] = value;
|
||||
});
|
||||
}
|
||||
|
||||
Attribute.prototype = {
|
||||
toString: function() { return this.names[0]; },
|
||||
|
||||
matchRules: {
|
||||
// See: https://ldap.com/matching-rules/
|
||||
distinguishedNameMatch: (a, b) => new DN(a).eq(new DN(b)),
|
||||
caseIgnoreIA5Match: (a, b) => a.toLowerCase() == b.toLowerCase(),
|
||||
caseIgnoreMatch: (a, b) => a.toLowerCase() == b.toLowerCase(),
|
||||
// generalizedTimeMatch: ...
|
||||
integerMatch: (a, b) => +a == +b,
|
||||
numericStringMatch: (a, b) => +a == +b,
|
||||
octetStringMatch: (a, b) => a == b,
|
||||
},
|
||||
|
||||
get matcher() {
|
||||
return this.matchRules[this.equality]
|
||||
|| this.matchRules.octetStringMatch;
|
||||
},
|
||||
|
||||
eq: function(other) { return other && this.oid == other.oid; },
|
||||
|
||||
get binary() {
|
||||
if (this.equality == 'octetStringMatch') return undefined;
|
||||
return this.$syntax.not_human_readable;
|
||||
},
|
||||
|
||||
get $syntax() { return this.$syntaxes[this.syntax]; },
|
||||
|
||||
get $super() {
|
||||
const parent = Object.getPrototypeOf(this);
|
||||
return parent.sup ? parent : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
function Syntax(json) {
|
||||
Object.assign(this, json);
|
||||
}
|
||||
|
||||
Syntax.prototype = {
|
||||
toString: function() { return this.desc; },
|
||||
};
|
||||
|
||||
function PropertyMap(json, ctor, prop) {
|
||||
Object.getOwnPropertyNames(json || {})
|
||||
.map(prop => new ctor(json[prop]))
|
||||
.forEach(obj => { this[obj[prop]] = obj; });
|
||||
}
|
||||
|
||||
function FlatPropertyMap(json, ctor, prop) {
|
||||
this.$values = Object.getOwnPropertyNames(json || {})
|
||||
.map(key => new ctor(json[key]));
|
||||
|
||||
// Map objects to each available prop value
|
||||
this.$values.forEach(obj => obj[prop].forEach(
|
||||
key => { this[key.toLowerCase()] = obj; }));
|
||||
|
||||
// Model object inheritance as JS prototype chain
|
||||
this.$values.forEach(obj => {
|
||||
const key = obj.sup[0],
|
||||
parent = key ? this[key.toLowerCase()] : undefined;
|
||||
if (parent) Object.setPrototypeOf(obj, parent);
|
||||
});
|
||||
|
||||
this.$get = function(name) {
|
||||
return name ? this[name.toLowerCase()] : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// LdapSchema constructor
|
||||
this.DN = DN;
|
||||
this.RDN = RDN;
|
||||
this.Attribute = Attribute;
|
||||
this.ObjectClass = ObjectClass;
|
||||
|
||||
Attribute.prototype.$syntaxes = new PropertyMap(json.syntaxes, Syntax, 'oid');
|
||||
ObjectClass.prototype.$attributes = new FlatPropertyMap(json.attributes, Attribute, 'names'),
|
||||
RDN.prototype.$attributes = ObjectClass.prototype.$attributes;
|
||||
ObjectClass.values = new FlatPropertyMap(json.objectClasses, ObjectClass, 'names');
|
||||
|
||||
this.attr = (name) => ObjectClass.prototype.$attributes.$get(name);
|
||||
this.oc = (name) => ObjectClass.values.$get(name);
|
||||
this.search = (q) => ObjectClass.prototype.$attributes.$values
|
||||
.filter(attr => attr.name.toLowerCase().startsWith(q.toLowerCase()));
|
||||
}
|
@ -1,19 +1,18 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { LdapSchema } from './schema.js';
|
||||
import jsonSchema from './test-schema.json';
|
||||
|
||||
const schema = new LdapSchema(jsonSchema);
|
||||
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 schema.DN('dc=foo,dc=bar'), rdn1 = dn1.rdn,
|
||||
dn2 = new schema.DN('domainComponent=FOO,domainComponent=BAR'), rdn2 = dn2.rdn,
|
||||
dn3 = new schema.DN('domainComponent=bar'), rdn3 = dn3.rdn;
|
||||
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;
|
||||
|
||||
test('Test RDN attribute equality', () =>
|
||||
expect(rdn1.attr).toEqual(schema.attr('domainComponent')));
|
||||
expect(rdn1.attr).toEqual(sut.attr('domainComponent')));
|
||||
|
||||
test('Test RDN equality', () =>
|
||||
expect(rdn1.eq(rdn2)).toBeTruthy());
|
||||
@ -25,7 +24,7 @@ describe('LDAP schema items', () => {
|
||||
expect(dn1.rdn.eq(rdn2)).toBeTruthy());
|
||||
|
||||
test('Test DN parent', () =>
|
||||
expect(dn2.parent.eq(dn3)).toBeTruthy());
|
||||
expect(dn2.parent?.eq(dn3)).toBeTruthy());
|
||||
|
||||
test('Test DN grandparent', () =>
|
||||
expect(dn3.parent).toBeUndefined());
|
||||
@ -35,8 +34,8 @@ describe('LDAP schema items', () => {
|
||||
});
|
||||
|
||||
describe('Attributes', () => {
|
||||
const sn = schema.attr('sn'),
|
||||
name = schema.attr('name');
|
||||
const sn = sut.attr('sn'),
|
||||
name = sut.attr('name');
|
||||
|
||||
test('SN is found in schema', () =>
|
||||
expect(sn).toBeDefined());
|
||||
@ -45,24 +44,27 @@ describe('LDAP schema items', () => {
|
||||
expect(Object.getPrototypeOf(sn)).toEqual(name));
|
||||
|
||||
test('SN is an Attribute', () =>
|
||||
expect(sn).toBeInstanceOf(schema.Attribute));
|
||||
expect(sn).toBeInstanceOf(Attribute));
|
||||
|
||||
test('SN has no own equality', () =>
|
||||
expect(Object.getOwnPropertyNames(sn)).not.toContain('equality'));
|
||||
|
||||
test('SN inherits equality from name', () =>
|
||||
expect(sn.equality).toBeDefined());
|
||||
expect(sn?.equality).toBeDefined());
|
||||
|
||||
test('SN syntax resolution', () =>
|
||||
expect(sn.$syntax.toString()).toEqual('Directory String'));
|
||||
expect(sn?.$syntax?.toString()).toEqual('Directory String'));
|
||||
|
||||
test('Search for SN', () =>
|
||||
expect(sut.search('sur')).toEqual([sn]));
|
||||
});
|
||||
|
||||
describe('ObjectClass inheritance', () => {
|
||||
const top = schema.oc('top'),
|
||||
dnsDomain = schema.oc('dnsDomain');
|
||||
const top = sut.oc('top'),
|
||||
dnsDomain = sut.oc('dnsDomain');
|
||||
|
||||
function superClasses(cls) {
|
||||
let result = [];
|
||||
function superClasses(cls: ObjectClass | undefined) : ObjectClass[] {
|
||||
const result = [];
|
||||
for (let oc = cls; oc; oc = Object.getPrototypeOf(oc)) {
|
||||
result.push(oc);
|
||||
}
|
||||
@ -70,7 +72,7 @@ describe('LDAP schema items', () => {
|
||||
}
|
||||
|
||||
test('top is an ObjectClass', () =>
|
||||
expect(top).toBeInstanceOf(schema.ObjectClass));
|
||||
expect(top).toBeInstanceOf(ObjectClass));
|
||||
|
||||
test('dnsDomain inherits from top', () =>
|
||||
expect(superClasses(dnsDomain)).toContain(top));
|
233
src/components/schema/schema.ts
Normal file
233
src/components/schema/schema.ts
Normal file
@ -0,0 +1,233 @@
|
||||
"use strict";
|
||||
|
||||
function unique(element: unknown, index: number, array: Array<unknown>): boolean {
|
||||
return array.indexOf(element) == index;
|
||||
}
|
||||
|
||||
export function generalizedTime(dt: string): Date {
|
||||
let tz = dt.substring(14);
|
||||
if (tz != 'Z') {
|
||||
tz = tz.substring(0, 3) + ':'
|
||||
+ (tz.length > 3 ? tz.substring(3, 5) : '00');
|
||||
}
|
||||
return new Date(dt.substring(0, 4) + '-'
|
||||
+ dt.substring( 4, 6) + '-'
|
||||
+ dt.substring( 6, 8) + 'T'
|
||||
+ dt.substring( 8, 10) + ':'
|
||||
+ dt.substring(10, 12) + ':'
|
||||
+ dt.substring(12, 14) + tz);
|
||||
}
|
||||
|
||||
let schema: LdapSchema;
|
||||
|
||||
export class RDN {
|
||||
readonly text: string;
|
||||
readonly attrName: string;
|
||||
readonly value: string;
|
||||
|
||||
constructor(value: string) {
|
||||
this.text = value;
|
||||
const parts = value.split('=');
|
||||
this.attrName = parts[0].trim();
|
||||
this.value = parts[1].trim();
|
||||
}
|
||||
|
||||
toString() { return this.text; }
|
||||
|
||||
eq(other: RDN | undefined) {
|
||||
return other !== undefined
|
||||
&& this.attr !== undefined
|
||||
&& this.attr.eq(other.attr)
|
||||
&& this.attr.matcher(this.value, other.value);
|
||||
}
|
||||
|
||||
get attr() {
|
||||
return schema.attr(this.attrName);
|
||||
}
|
||||
}
|
||||
|
||||
export class DN {
|
||||
readonly text: string;
|
||||
readonly rdn: RDN;
|
||||
readonly parent: DN | undefined;
|
||||
|
||||
constructor(value: string) {
|
||||
this.text = value;
|
||||
const parts = value.split(',');
|
||||
this.rdn = new RDN(parts[0]);
|
||||
this.parent = parts.length == 1 ? undefined
|
||||
: new DN(value.slice(parts[0].length + 1));
|
||||
}
|
||||
|
||||
toString() { return this.text; }
|
||||
|
||||
eq(other: DN | undefined) : boolean {
|
||||
if (!other || !this.rdn.eq(other.rdn)) return false;
|
||||
if (!this.parent && !other.parent) return true;
|
||||
return !!this.parent && this.parent.eq(other.parent!);
|
||||
}
|
||||
}
|
||||
|
||||
class Element {
|
||||
readonly oid?: string;
|
||||
readonly name?: string;
|
||||
readonly names?: string[];
|
||||
readonly sup?: string[];
|
||||
}
|
||||
|
||||
export class ObjectClass extends Element {
|
||||
readonly desc?: string;
|
||||
readonly obsolete?: boolean;
|
||||
readonly may?: string[];
|
||||
readonly must?: string[];
|
||||
readonly kind?: string;
|
||||
|
||||
constructor(json: object) {
|
||||
super();
|
||||
Object.assign(this, json);
|
||||
}
|
||||
|
||||
get structural() { return this.kind == 'structural'; }
|
||||
|
||||
// gather values from a field across all superclasses
|
||||
$collect(name: "must" | "may"): string[] {
|
||||
const attributes = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
for (let oc: ObjectClass | undefined = this; oc; oc = oc.$super) {
|
||||
const attrs = oc[name];
|
||||
if (attrs) attributes.push(attrs);
|
||||
}
|
||||
|
||||
const result = attributes.flat()
|
||||
.map(attr => schema.attr(attr))
|
||||
.map(obj => obj?.name)
|
||||
.filter(unique) as string[];
|
||||
result.sort();
|
||||
return result;
|
||||
}
|
||||
|
||||
toString() { return this.name!; }
|
||||
|
||||
get $super(): ObjectClass | undefined {
|
||||
const parent = Object.getPrototypeOf(this) as ObjectClass;
|
||||
return parent.sup ? parent : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const matchRules: {[key: string]: (a: string, b: string) => boolean} = {
|
||||
// See: https://ldap.com/matching-rules/
|
||||
distinguishedNameMatch: (a: string, b: string) => new DN(a).eq(new DN(b)),
|
||||
caseIgnoreIA5Match: (a: string, b: string) => a.toLowerCase() == b.toLowerCase(),
|
||||
caseIgnoreMatch: (a: string, b: string) => a.toLowerCase() == b.toLowerCase(),
|
||||
// generalizedTimeMatch: ...
|
||||
integerMatch: (a: string, b: string) => +a == +b,
|
||||
numericStringMatch: (a: string, b: string) => +a == +b,
|
||||
octetStringMatch: (a: string, b: string) => a == b,
|
||||
};
|
||||
|
||||
export class Attribute extends Element {
|
||||
desc?: string;
|
||||
equality?: string; // possibly null in JSON
|
||||
obsolete?: boolean;
|
||||
ordering?: string; // possibly null in JSON
|
||||
no_user_mod?: boolean;
|
||||
single_value?: boolean;
|
||||
substr?: string; // possibly null in JSON
|
||||
syntax?: string; // possibly null in JSON
|
||||
usage?: string;
|
||||
|
||||
constructor(json: object) {
|
||||
super();
|
||||
|
||||
// Hack alert: Wipe undefined attributes,
|
||||
// they are looked up via the prototype chain
|
||||
delete this.equality;
|
||||
delete this.ordering;
|
||||
delete this.substr;
|
||||
delete this.syntax;
|
||||
// End of hack
|
||||
|
||||
Object.assign(this, Object.fromEntries(Object.entries(json)
|
||||
.filter(([_prop, value]) => value != null)));
|
||||
}
|
||||
|
||||
toString() { return this.name!; }
|
||||
|
||||
get matcher() {
|
||||
return (this.equality ? matchRules[this.equality] : undefined)
|
||||
|| matchRules.octetStringMatch;
|
||||
}
|
||||
|
||||
eq(other: Attribute | undefined) {
|
||||
return other && this.oid == other.oid;
|
||||
}
|
||||
|
||||
get binary() {
|
||||
if (this.equality == 'octetStringMatch') return undefined;
|
||||
return this.$syntax?.not_human_readable;
|
||||
}
|
||||
|
||||
get $syntax() { return schema.syntaxes.get(this.syntax!); }
|
||||
|
||||
get $super() {
|
||||
const parent = Object.getPrototypeOf(this);
|
||||
return parent.sup ? parent : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class Syntax {
|
||||
readonly oid?: string;
|
||||
readonly desc?: string;
|
||||
readonly not_human_readable?: boolean;
|
||||
|
||||
constructor(json: object) {
|
||||
Object.assign(this, json);
|
||||
}
|
||||
|
||||
toString() { return this.desc!; }
|
||||
}
|
||||
|
||||
interface JsonSchema {
|
||||
attributes: object;
|
||||
objectClasses: object;
|
||||
syntaxes: object;
|
||||
}
|
||||
|
||||
export class LdapSchema extends Object {
|
||||
readonly attributes: Array<Attribute>;
|
||||
readonly objectClasses: Map<string, ObjectClass>;
|
||||
readonly syntaxes: Map<string, Syntax>;
|
||||
readonly attributesByName: Map<string, Attribute>;
|
||||
|
||||
constructor(json: JsonSchema) {
|
||||
super();
|
||||
this.syntaxes = new Map(Object.entries(json.syntaxes)
|
||||
.map(([oid, obj]) => [oid, new Syntax(obj)]));
|
||||
this.attributes = Object.values(json.attributes)
|
||||
.map(obj => new Attribute(obj));
|
||||
this.objectClasses = new Map(Object.entries(json.objectClasses)
|
||||
.map(([key, obj]) => [key.toLowerCase(), new ObjectClass(obj)]));
|
||||
this.buildPrototypeChain(this.objectClasses);
|
||||
|
||||
this.attributesByName = new Map(this.attributes.flatMap(
|
||||
attr => (attr.names || []).map(name => [name.toLowerCase(), attr])));
|
||||
this.buildPrototypeChain(this.attributesByName);
|
||||
schema = this as LdapSchema;
|
||||
}
|
||||
|
||||
private buildPrototypeChain(elements: Map<string, Element>): void {
|
||||
for (const element of elements.values()) {
|
||||
const key = element.sup ? element.sup[0] : undefined,
|
||||
parent = key ? elements.get(key.toLowerCase()) : undefined;
|
||||
if (parent) Object.setPrototypeOf(element, parent);
|
||||
}
|
||||
}
|
||||
|
||||
attr(name: string | undefined) { return this.attributesByName.get(name?.toLowerCase() || ''); }
|
||||
oc(name: string | undefined) { return this.objectClasses.get(name?.toLowerCase() || ''); }
|
||||
|
||||
search(q: string) {
|
||||
return this.attributes.filter(
|
||||
attr => attr.names?.some(name => name.toLowerCase().startsWith(q.toLowerCase())));
|
||||
}
|
||||
}
|
@ -14,10 +14,10 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits('close');
|
||||
const emit = defineEmits(['close']);
|
||||
</script>
|
@ -15,7 +15,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Popover from './Popover.vue';
|
||||
|
||||
|
@ -44,7 +44,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
open: { type: Boolean, required: true },
|
||||
@ -63,7 +63,7 @@
|
||||
|
||||
function onCancel() {
|
||||
if (props.open) {
|
||||
if (props.returnTo) document.getElementById(props.returnTo).focus();
|
||||
if (props.returnTo) document.getElementById(props.returnTo)?.focus();
|
||||
emit('cancel');
|
||||
}
|
||||
}
|
||||
|
@ -9,14 +9,14 @@
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useEventListener, useMouseInElement } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({ open: Boolean }),
|
||||
emit = defineEmits(['opened', 'closed', 'update:open', 'select']),
|
||||
items = ref(null),
|
||||
selected = ref(null),
|
||||
emit = defineEmits(['opened', 'closed', 'update:open']),
|
||||
items = ref<HTMLElement | null>(null),
|
||||
selected = ref<number>(),
|
||||
{ isOutside } = useMouseInElement(items);
|
||||
|
||||
function close() {
|
||||
@ -24,9 +24,9 @@
|
||||
if (props.open) emit('update:open');
|
||||
}
|
||||
|
||||
function move(offset) {
|
||||
const maxpos = items.value.children.length - 1;
|
||||
if (selected.value == null) {
|
||||
function move(offset: number) {
|
||||
const maxpos = items.value!.children.length - 1;
|
||||
if (selected.value === undefined) {
|
||||
selected.value = offset > 0 ? 0 : maxpos;
|
||||
}
|
||||
else {
|
||||
@ -36,7 +36,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function scroll(e) {
|
||||
function scroll(e: KeyboardEvent) {
|
||||
if (!props.open || !items.value) return;
|
||||
switch (e.key) {
|
||||
case 'Esc':
|
||||
@ -51,10 +51,12 @@
|
||||
move(-1);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'Enter':
|
||||
emit('select', selected.value);
|
||||
case 'Enter': {
|
||||
const target = items.value.children[selected.value!] as HTMLElement;
|
||||
target.click();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,19 +67,19 @@
|
||||
|
||||
watch(selected, (pos) => {
|
||||
if (!props.open || !items.value) return;
|
||||
for (let child of items.value.children) {
|
||||
for (const child of items.value.children) {
|
||||
child.classList.remove('selected');
|
||||
}
|
||||
if (pos != null) items.value.children[pos].classList.add('selected');
|
||||
if (pos != undefined) items.value.children[pos].classList.add('selected');
|
||||
});
|
||||
|
||||
watch(isOutside, (outside) => {
|
||||
for (let child of items.value.children) {
|
||||
for (const child of items.value!.children) {
|
||||
if (outside) {
|
||||
child.classList.remove('hover:bg-primary/40');
|
||||
}
|
||||
else {
|
||||
selected.value = null;
|
||||
selected.value = undefined;
|
||||
child.classList.add('hover:bg-primary/40');
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
@ -1,44 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
/* See: https://stackoverflow.com/questions/30008114/how-do-i-promisify-native-xhr#30008115
|
||||
*
|
||||
* opts = {
|
||||
* method: String,
|
||||
* url: String,
|
||||
* data: String | Object,
|
||||
* headers: Object,
|
||||
* responseType: String,
|
||||
* binary: Boolean,
|
||||
* }
|
||||
*/
|
||||
export function request(opts) {
|
||||
return new Promise(function(resolve, reject) { // eslint-disable-line no-undef
|
||||
var xhr = new XMLHttpRequest(); // eslint-disable-line no-undef
|
||||
xhr.open(opts.method || 'GET', opts.url);
|
||||
if (opts.responseType) xhr.responseType = opts.responseType;
|
||||
xhr.onload = function () {
|
||||
if (this.status >= 200 && this.status < 300) {
|
||||
resolve(xhr);
|
||||
} else {
|
||||
reject(this);
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
reject(this);
|
||||
};
|
||||
if (opts.headers) {
|
||||
Object.keys(opts.headers).forEach(function (key) {
|
||||
xhr.setRequestHeader(key, opts.headers[key]);
|
||||
});
|
||||
}
|
||||
var params = opts.data;
|
||||
// We'll need to stringify if we've been given an object
|
||||
// If we have a string, this is skipped.
|
||||
if (params && typeof params === 'object' && !opts.binary) {
|
||||
params = Object.keys(params).map(function (key) {
|
||||
return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
|
||||
}).join('&');
|
||||
}
|
||||
xhr.send(params);
|
||||
});
|
||||
}
|
43
src/request.ts
Normal file
43
src/request.ts
Normal file
@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
|
||||
// See: https://stackoverflow.com/questions/30008114/how-do-i-promisify-native-xhr#30008115
|
||||
|
||||
export type BagOfStrings = {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
url: string;
|
||||
method?: string;
|
||||
responseType?: XMLHttpRequestResponseType;
|
||||
headers?: BagOfStrings;
|
||||
binary?: boolean;
|
||||
data?: BagOfStrings | XMLHttpRequestBodyInit;
|
||||
}
|
||||
|
||||
export function request(opts: Options) : Promise<XMLHttpRequest> {
|
||||
return new Promise(function(resolve, reject) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(opts.method || 'GET', opts.url);
|
||||
if (opts.responseType) xhr.responseType = opts.responseType;
|
||||
xhr.onload = function () {
|
||||
if (this.status >= 200 && this.status < 300) resolve(xhr);
|
||||
else reject(this);
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
reject(this);
|
||||
};
|
||||
if (opts.headers) {
|
||||
Object.keys(opts.headers).forEach(key => xhr.setRequestHeader(key, opts.headers![key]));
|
||||
}
|
||||
let params = opts.data;
|
||||
// We'll need to stringify if we've been given an object
|
||||
// If we have a string, this is skipped.
|
||||
if (params && typeof params === 'object' && !opts.binary) {
|
||||
const qs = new URLSearchParams();
|
||||
Object.keys(params).forEach(key => qs.set(key, (params as BagOfStrings)[key]));
|
||||
params = qs;
|
||||
}
|
||||
xhr.send(params as XMLHttpRequestBodyInit);
|
||||
});
|
||||
}
|
13
tsconfig.app.json
Normal file
13
tsconfig.app.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.js", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
@ -17,6 +18,12 @@ export default defineConfig({
|
||||
chunkSizeWarningLimit: 600
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/': {
|
Loading…
Reference in New Issue
Block a user