Migrate to TypeScript

This commit is contained in:
dnknth 2024-02-23 17:09:12 +01:00
parent e6de333716
commit d2ec7ed9b5
45 changed files with 903 additions and 671 deletions

8
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss"
]
}

2
app.py
View File

@ -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': [],
}
}

View File

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

Binary file not shown.

View File

@ -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": [

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,6 @@
export interface TreeNode {
dn: string;
level?: number;
hasSubordinates: boolean;
structuralObjectClass: string;
}

View File

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

View File

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

View File

@ -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']);

View File

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

View File

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

View File

@ -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 = [];

View File

@ -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() {

View File

@ -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() {

View File

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

View 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[];
}

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View 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())));
}
}

View File

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

View File

@ -15,7 +15,7 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref } from 'vue';
import Popover from './Popover.vue';

View File

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

View File

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

View File

@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { computed } from 'vue';

View File

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

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

19
tsconfig.node.json Normal file
View 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"]
}
}

View File

@ -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/': {