ldap-rest 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/bin/sync-james.mjs +6 -2
  2. package/dist/bin/index.js +1 -1
  3. package/dist/config/args.js +1 -1
  4. package/dist/lib/auth/base.js +1 -1
  5. package/dist/lib/ldapSchema.js +1 -0
  6. package/dist/plugins/auth/authzPerRoute.js +1 -1
  7. package/dist/plugins/auth/rateLimit.js +1 -1
  8. package/dist/plugins/ldap/organizations.js +1 -1
  9. package/dist/plugins/ldap/raw.js +1 -0
  10. package/dist/plugins/twake/appAccountsApi.js +1 -1
  11. package/dist/src/bin/index.d.ts +112 -0
  12. package/dist/src/browser/ldap-browser/LdapBrowser.d.ts +52 -0
  13. package/dist/src/browser/ldap-browser/api/RawApiClient.d.ts +64 -0
  14. package/dist/src/browser/ldap-browser/components/EntryTree.d.ts +75 -0
  15. package/dist/src/browser/ldap-browser/components/EntryView.d.ts +56 -0
  16. package/dist/src/browser/ldap-browser/index.d.ts +9 -0
  17. package/dist/src/browser/ldap-browser/schema.d.ts +62 -0
  18. package/dist/src/browser/ldap-browser/types.d.ts +96 -0
  19. package/dist/src/config/args.d.ts +6 -0
  20. package/dist/src/lib/auth/base.d.ts +82 -1
  21. package/dist/src/lib/ldapSchema.d.ts +159 -0
  22. package/dist/src/plugins/ldap/raw.d.ts +251 -0
  23. package/docs/client-development/browser/libraries.md +142 -1
  24. package/docs/usage/configuration.md +20 -0
  25. package/docs/usage/plugins/README.md +10 -9
  26. package/docs/usage/plugins/auth/README.md +57 -0
  27. package/docs/usage/plugins/auth/rate-limit.md +9 -0
  28. package/docs/usage/plugins/integrations/app-accounts.md +32 -0
  29. package/docs/usage/plugins/ldap/README.md +12 -0
  30. package/docs/usage/plugins/ldap/raw.md +226 -0
  31. package/examples/web/README.md +18 -0
  32. package/examples/web/ldap-browser.html +94 -0
  33. package/package.json +37 -1
  34. package/static/browser/ldap-browser/LdapBrowser.js +199 -0
  35. package/static/browser/ldap-browser/LdapBrowser.js.map +1 -0
  36. package/static/browser/ldap-browser/api/RawApiClient.js +103 -0
  37. package/static/browser/ldap-browser/api/RawApiClient.js.map +1 -0
  38. package/static/browser/ldap-browser/components/EntryTree.js +235 -0
  39. package/static/browser/ldap-browser/components/EntryTree.js.map +1 -0
  40. package/static/browser/ldap-browser/components/EntryView.js +183 -0
  41. package/static/browser/ldap-browser/components/EntryView.js.map +1 -0
  42. package/static/browser/ldap-browser/index.js +4 -0
  43. package/static/browser/ldap-browser/index.js.map +1 -0
  44. package/static/browser/ldap-browser/schema.js +135 -0
  45. package/static/browser/ldap-browser/schema.js.map +1 -0
  46. package/static/browser/ldap-browser/types.js +2 -0
  47. package/static/browser/ldap-browser/types.js.map +1 -0
  48. package/static/browser/ldap-browser.css +2 -0
  49. package/static/browser/ldap-browser.css.map +1 -0
  50. package/static/browser/ldap-browser.esm.js +915 -0
  51. package/static/browser/ldap-browser.esm.js.map +1 -0
  52. package/static/browser/ldap-browser.js +925 -0
  53. package/static/browser/ldap-browser.js.map +1 -0
  54. package/static/browser/ldap-browser.min.js +2 -0
  55. package/static/browser/ldap-browser.min.js.map +1 -0
  56. package/static/browser/ldap-unit-editor/components/UnitTree.js +0 -1
  57. package/static/browser/ldap-unit-editor/components/UnitTree.js.map +1 -1
  58. package/static/browser/ldap-unit-editor.esm.js +0 -1
  59. package/static/browser/ldap-unit-editor.esm.js.map +1 -1
  60. package/static/browser/ldap-unit-editor.js +0 -1
  61. package/static/browser/ldap-unit-editor.js.map +1 -1
  62. package/static/browser/ldap-unit-editor.min.js.map +1 -1
@@ -0,0 +1,925 @@
1
+ (function (global, factory) {
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.LdapBrowser = {}));
5
+ })(this, (function (exports) { 'use strict';
6
+
7
+ /**
8
+ * DOM utility functions
9
+ * @module browser/shared/utils/dom
10
+ */
11
+ /**
12
+ * Escape HTML special characters to prevent XSS attacks
13
+ * Reference: https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html
14
+ */
15
+ function escapeHtml(text) {
16
+ if (text == null)
17
+ return '';
18
+ const map = {
19
+ '&': '&',
20
+ '<': '&lt;',
21
+ '>': '&gt;',
22
+ '"': '&quot;',
23
+ "'": '&#x27;',
24
+ '/': '&#x2F;',
25
+ };
26
+ return String(text).replace(/[&<>"'/]/g, m => map[m]);
27
+ }
28
+
29
+ /**
30
+ * Client of the low-level LDAP API (`core/ldap/raw` plugin).
31
+ * @module browser/ldap-browser/api/RawApiClient
32
+ */
33
+ class RawApiClient {
34
+ constructor(baseUrl = '', authToken) {
35
+ this.baseUrl = baseUrl.replace(/\/$/, '');
36
+ this.authToken = authToken;
37
+ }
38
+ /**
39
+ * Issue a GET on the raw API and decode its JSON answer.
40
+ *
41
+ * @param path path below `/api/v1/ldap/raw`
42
+ * @returns decoded body
43
+ * @throws Error carrying the server message when the status is not 2xx
44
+ */
45
+ async get(path) {
46
+ const headers = { Accept: 'application/json' };
47
+ if (this.authToken)
48
+ headers.Authorization = `Bearer ${this.authToken}`;
49
+ const response = await fetch(`${this.baseUrl}/api/v1/ldap/raw${path}`, {
50
+ headers,
51
+ });
52
+ if (!response.ok) {
53
+ let message = response.statusText;
54
+ try {
55
+ const body = (await response.json());
56
+ if (body.error)
57
+ message = body.error;
58
+ }
59
+ catch {
60
+ // keep the status text
61
+ }
62
+ throw new Error(`${response.status}: ${message}`);
63
+ }
64
+ return (await response.json());
65
+ }
66
+ /**
67
+ * Subtrees the server exposes, used as roots of the tree.
68
+ *
69
+ * @returns list of base DNs
70
+ */
71
+ async getBases() {
72
+ const data = await this.get('/bases');
73
+ return data.bases;
74
+ }
75
+ /**
76
+ * Server capabilities.
77
+ *
78
+ * @returns root DSE
79
+ */
80
+ getRootDse() {
81
+ return this.get('/rootdse');
82
+ }
83
+ /**
84
+ * Directory schema.
85
+ *
86
+ * @returns object classes, attribute types, syntaxes and matching rules
87
+ */
88
+ getSchema() {
89
+ return this.get('/schema');
90
+ }
91
+ /**
92
+ * Read one entry.
93
+ *
94
+ * @param dn DN of the entry
95
+ * @returns the entry with all its attributes
96
+ */
97
+ getEntry(dn) {
98
+ return this.get(`/entry/${encodeURIComponent(dn)}`);
99
+ }
100
+ /**
101
+ * List the direct children of an entry.
102
+ *
103
+ * @param dn DN of the parent
104
+ * @param withChildrenFlag ask the server which children have children
105
+ * @returns children sorted by RDN, with the truncation flag
106
+ */
107
+ getChildren(dn, withChildrenFlag = true) {
108
+ return this.get(`/children/${encodeURIComponent(dn)}${withChildrenFlag ? '?children=1' : ''}`);
109
+ }
110
+ /**
111
+ * Run a search.
112
+ *
113
+ * @param params search base, scope, filter and limit
114
+ * @returns matching entries
115
+ */
116
+ search(params) {
117
+ const query = new URLSearchParams({ base: params.base });
118
+ if (params.scope)
119
+ query.set('scope', params.scope);
120
+ if (params.filter)
121
+ query.set('filter', params.filter);
122
+ if (params.attributes?.length)
123
+ query.set('attributes', params.attributes.join(','));
124
+ if (params.limit)
125
+ query.set('limit', String(params.limit));
126
+ return this.get(`/search?${query.toString()}`);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Base class for browser components with proper lifecycle management
132
+ * Ensures all event listeners and intervals are cleaned up on destroy
133
+ * @module browser/shared/components/DisposableComponent
134
+ */
135
+ class DisposableComponent {
136
+ constructor() {
137
+ this._eventListeners = [];
138
+ this._intervals = [];
139
+ this._isDestroyed = false;
140
+ }
141
+ /**
142
+ * Register an event listener for automatic cleanup
143
+ * Use this instead of element.addEventListener directly
144
+ */
145
+ addManagedEventListener(target, type, listener, options) {
146
+ if (this._isDestroyed)
147
+ return;
148
+ target.addEventListener(type, listener, options);
149
+ this._eventListeners.push({ target, type, listener, options });
150
+ }
151
+ /**
152
+ * Register an interval for automatic cleanup
153
+ * Use this instead of setInterval directly
154
+ */
155
+ addManagedInterval(callback, ms) {
156
+ const id = setInterval(callback, ms);
157
+ this._intervals.push(id);
158
+ return id;
159
+ }
160
+ /**
161
+ * Check if the component has been destroyed
162
+ */
163
+ get isDestroyed() {
164
+ return this._isDestroyed;
165
+ }
166
+ /**
167
+ * Clean up all managed resources
168
+ * Override in subclasses but ALWAYS call super.destroy()
169
+ */
170
+ destroy() {
171
+ if (this._isDestroyed)
172
+ return;
173
+ this._isDestroyed = true;
174
+ // Remove all event listeners
175
+ for (const { target, type, listener, options } of this._eventListeners) {
176
+ target.removeEventListener(type, listener, options);
177
+ }
178
+ this._eventListeners = [];
179
+ // Clear all intervals
180
+ for (const id of this._intervals) {
181
+ clearInterval(id);
182
+ }
183
+ this._intervals = [];
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Lazy directory tree: one root per exposed base, children fetched on expand.
189
+ * @module browser/ldap-browser/components/EntryTree
190
+ */
191
+ /** Icon picked from the object classes of an entry */
192
+ function iconFor(objectClass) {
193
+ const classes = objectClass.map(c => c.toLowerCase());
194
+ if (classes.includes('organizationalunit') ||
195
+ classes.includes('organization'))
196
+ return 'folder';
197
+ if (classes.includes('groupofnames') ||
198
+ classes.includes('groupofuniquenames') ||
199
+ classes.includes('posixgroup'))
200
+ return 'group';
201
+ if (classes.includes('inetorgperson') ||
202
+ classes.includes('person') ||
203
+ classes.includes('organizationalperson'))
204
+ return 'person';
205
+ if (classes.includes('domain') || classes.includes('dcobject'))
206
+ return 'dns';
207
+ return 'article';
208
+ }
209
+ class EntryTree extends DisposableComponent {
210
+ constructor(container, api, onSelect, onError) {
211
+ super();
212
+ this.roots = [];
213
+ this.children = new Map();
214
+ this.expanded = new Set();
215
+ this.loading = new Set();
216
+ this.selectedDn = null;
217
+ this.container = container;
218
+ this.api = api;
219
+ this.onSelect = onSelect;
220
+ this.onError = onError;
221
+ this.addManagedEventListener(this.container, 'click', event => {
222
+ const target = event.target.closest('[data-dn]');
223
+ if (!target)
224
+ return;
225
+ const dn = target.dataset.dn;
226
+ if (target.classList.contains('ldap-browser-tree__toggle')) {
227
+ void this.toggle(dn);
228
+ }
229
+ else {
230
+ void this.select(dn);
231
+ }
232
+ });
233
+ }
234
+ /**
235
+ * Load the exposed bases and draw them as roots.
236
+ *
237
+ * @param bases base DNs to use as roots
238
+ */
239
+ async init(bases) {
240
+ this.roots = bases;
241
+ // Open the first root so the tree is never empty on arrival
242
+ if (bases.length > 0)
243
+ await this.expand(bases[0]);
244
+ this.render();
245
+ }
246
+ /**
247
+ * DN currently highlighted in the tree.
248
+ *
249
+ * @returns selected DN, or null
250
+ */
251
+ getSelectedDn() {
252
+ return this.selectedDn;
253
+ }
254
+ /**
255
+ * Highlight an entry and notify the parent component.
256
+ *
257
+ * @param dn DN to select
258
+ * @returns whatever the selection handler returns, so callers can await
259
+ * the entry actually being displayed
260
+ */
261
+ select(dn) {
262
+ this.selectedDn = dn;
263
+ this.render();
264
+ return this.onSelect(dn);
265
+ }
266
+ /**
267
+ * Expand the tree down to a DN, loading every level on the way, then
268
+ * select it. Used to follow a search result or a DN typed by the user.
269
+ *
270
+ * @param dn DN to reveal
271
+ */
272
+ async revealAndSelect(dn) {
273
+ const root = this.roots.find(base => dn.toLowerCase() === base.toLowerCase() ||
274
+ dn.toLowerCase().endsWith(`,${base.toLowerCase()}`));
275
+ if (root) {
276
+ // Walk down from the root, expanding each ancestor
277
+ const relative = dn
278
+ .substring(0, dn.length - root.length)
279
+ .replace(/,$/, '');
280
+ const rdns = relative ? splitDn(relative) : [];
281
+ let current = root;
282
+ await this.expand(current);
283
+ // Expand the ancestors only: the target itself stays closed
284
+ for (let i = rdns.length - 1; i >= 1; i--) {
285
+ current = `${rdns[i]},${current}`;
286
+ await this.expand(current);
287
+ }
288
+ }
289
+ await this.select(dn);
290
+ }
291
+ /**
292
+ * Drop cached children so the next expand hits the server again.
293
+ *
294
+ * @param dn DN whose children must be reloaded, or undefined for all
295
+ */
296
+ invalidate(dn) {
297
+ if (dn)
298
+ this.children.delete(dn);
299
+ else
300
+ this.children.clear();
301
+ }
302
+ /**
303
+ * Expand a node, fetching its children the first time.
304
+ *
305
+ * @param dn DN of the node
306
+ */
307
+ async expand(dn) {
308
+ this.expanded.add(dn);
309
+ if (this.children.has(dn))
310
+ return;
311
+ this.loading.add(dn);
312
+ this.render();
313
+ try {
314
+ this.children.set(dn, await this.api.getChildren(dn));
315
+ }
316
+ catch (err) {
317
+ this.expanded.delete(dn);
318
+ this.onError(err);
319
+ }
320
+ finally {
321
+ this.loading.delete(dn);
322
+ this.render();
323
+ }
324
+ }
325
+ /**
326
+ * Expand or collapse a node.
327
+ *
328
+ * @param dn DN of the node
329
+ */
330
+ async toggle(dn) {
331
+ if (this.expanded.has(dn)) {
332
+ this.expanded.delete(dn);
333
+ this.render();
334
+ return;
335
+ }
336
+ await this.expand(dn);
337
+ }
338
+ /** Redraw the whole tree */
339
+ render() {
340
+ if (this.isDestroyed)
341
+ return;
342
+ this.container.innerHTML = `<ul class="ldap-browser-tree">${this.roots
343
+ .map(root => this.renderNode(root, root, ['dcObject'], true))
344
+ .join('')}</ul>`;
345
+ }
346
+ /**
347
+ * Render one node and, when expanded, its children.
348
+ *
349
+ * @param dn DN of the node
350
+ * @param label text shown for the node
351
+ * @param objectClass object classes, used to pick the icon
352
+ * @param expandable whether the node may have children
353
+ * @returns HTML of the node
354
+ */
355
+ renderNode(dn, label, objectClass, expandable) {
356
+ const isExpanded = this.expanded.has(dn);
357
+ const isLoading = this.loading.has(dn);
358
+ const isSelected = this.selectedDn === dn;
359
+ const children = this.children.get(dn);
360
+ const toggle = expandable
361
+ ? `<span class="ldap-browser-tree__toggle material-icons" data-dn="${escapeHtml(dn)}">${isLoading
362
+ ? 'hourglass_empty'
363
+ : isExpanded
364
+ ? 'expand_more'
365
+ : 'chevron_right'}</span>`
366
+ : '<span class="ldap-browser-tree__toggle ldap-browser-tree__toggle--leaf"></span>';
367
+ const sub = isExpanded && children
368
+ ? `<ul>${children.children.length === 0
369
+ ? '<li class="ldap-browser-tree__empty">(no child)</li>'
370
+ : children.children
371
+ .map(child => this.renderNode(child.dn, child.rdn, child.objectClass, child.hasChildren))
372
+ .join('')}${children.truncated
373
+ ? `<li class="ldap-browser-tree__truncated" title="Raise --ldap-raw-max-results to see more">
374
+ first ${children.children.length} entries only — use the search to find the others
375
+ </li>`
376
+ : ''}</ul>`
377
+ : '';
378
+ return `<li>
379
+ <div class="ldap-browser-tree__node${isSelected ? ' ldap-browser-tree__node--selected' : ''}">
380
+ ${toggle}
381
+ <span class="ldap-browser-tree__label" data-dn="${escapeHtml(dn)}" title="${escapeHtml(dn)}">
382
+ <span class="material-icons">${iconFor(objectClass)}</span>
383
+ <span class="ldap-browser-tree__name">${escapeHtml(label)}</span>
384
+ </span>
385
+ </div>
386
+ ${sub}
387
+ </li>`;
388
+ }
389
+ }
390
+ /**
391
+ * Split a DN into its components, honouring `\,` escapes.
392
+ *
393
+ * @param dn DN to split
394
+ * @returns RDN components, outermost first
395
+ */
396
+ function splitDn(dn) {
397
+ const parts = [];
398
+ let current = '';
399
+ for (let i = 0; i < dn.length; i++) {
400
+ if (dn[i] === '\\' && i + 1 < dn.length) {
401
+ current += dn[i] + dn[i + 1];
402
+ i++;
403
+ }
404
+ else if (dn[i] === ',') {
405
+ parts.push(current.trim());
406
+ current = '';
407
+ }
408
+ else {
409
+ current += dn[i];
410
+ }
411
+ }
412
+ if (current.trim())
413
+ parts.push(current.trim());
414
+ return parts;
415
+ }
416
+
417
+ /**
418
+ * Attribute table of one entry, annotated with the directory schema.
419
+ * @module browser/ldap-browser/components/EntryView
420
+ */
421
+ /** Operational attributes are shown apart from the ones the admin sets */
422
+ const OPERATIONAL_USAGE = new Set([
423
+ 'directoryOperation',
424
+ 'distributedOperation',
425
+ 'dSAOperation',
426
+ ]);
427
+ class EntryView {
428
+ constructor(container, schema) {
429
+ this.container = container;
430
+ this.schema = schema;
431
+ }
432
+ /** Draw the placeholder shown when nothing is selected */
433
+ renderEmpty() {
434
+ this.container.innerHTML = `
435
+ <div class="ldap-browser-entry__empty">
436
+ <span class="material-icons">description</span>
437
+ <p>Select an entry in the tree</p>
438
+ </div>`;
439
+ }
440
+ /**
441
+ * Draw an error in place of the entry.
442
+ *
443
+ * @param message message to display
444
+ */
445
+ renderError(message) {
446
+ this.container.innerHTML = `<div class="ldap-browser-entry__error">${escapeHtml(message)}</div>`;
447
+ }
448
+ /** Draw a loading indicator */
449
+ renderLoading() {
450
+ this.container.innerHTML =
451
+ '<div class="ldap-browser-entry__loading">Loading…</div>';
452
+ }
453
+ /**
454
+ * Draw an entry: its DN, its object classes with the attributes they allow,
455
+ * then the attribute table.
456
+ *
457
+ * @param entry entry to display
458
+ */
459
+ render(entry) {
460
+ const objectClasses = entry.attributes.objectClass?.values || [];
461
+ const resolved = this.schema?.resolveAttributes(objectClasses);
462
+ const mandatory = new Set((resolved?.must || []).map(name => name.toLowerCase()));
463
+ const present = new Set(Object.keys(entry.attributes).map(name => name.split(';')[0].toLowerCase()));
464
+ const names = Object.keys(entry.attributes).sort((a, b) => a.localeCompare(b));
465
+ const isOperational = (name) => {
466
+ const type = this.schema?.getAttributeType(name);
467
+ return type ? OPERATIONAL_USAGE.has(type.usage) : false;
468
+ };
469
+ const userAttributes = names.filter(name => !isOperational(name));
470
+ const operational = names.filter(isOperational);
471
+ // Mandatory attributes the entry does not carry: the directory should
472
+ // reject that, but a UI that hides it makes debugging harder
473
+ const missing = (resolved?.must || []).filter(name => !present.has(name.toLowerCase()));
474
+ this.container.innerHTML = `
475
+ <div class="ldap-browser-entry">
476
+ <h2 class="ldap-browser-entry__dn" title="${escapeHtml(entry.dn)}">
477
+ <span class="material-icons">description</span>
478
+ ${escapeHtml(entry.dn || '(root DSE)')}
479
+ </h2>
480
+
481
+ ${this.renderObjectClasses(objectClasses, resolved)}
482
+
483
+ ${missing.length
484
+ ? `<p class="ldap-browser-entry__warning">
485
+ Missing mandatory attribute(s): ${escapeHtml(missing.join(', '))}
486
+ </p>`
487
+ : ''}
488
+
489
+ ${this.renderTable(entry, mandatory, userAttributes, 'Attributes')}
490
+ ${operational.length
491
+ ? this.renderTable(entry, mandatory, operational, 'Operational attributes')
492
+ : ''}
493
+ </div>`;
494
+ }
495
+ /**
496
+ * Render the object class list and, when the schema is available, the
497
+ * attributes those classes allow.
498
+ *
499
+ * @param objectClasses object classes of the entry
500
+ * @param resolved mandatory and optional attributes they imply
501
+ * @returns HTML of the section
502
+ */
503
+ renderObjectClasses(objectClasses, resolved) {
504
+ if (objectClasses.length === 0)
505
+ return '';
506
+ const chips = objectClasses
507
+ .map(name => {
508
+ const definition = this.schema?.getObjectClass(name);
509
+ const title = definition
510
+ ? `${definition.kind}${definition.desc ? ` — ${definition.desc}` : ''}`
511
+ : 'unknown in schema';
512
+ return `<span class="ldap-browser-chip${definition ? '' : ' ldap-browser-chip--unknown'}" title="${escapeHtml(title)}">${escapeHtml(name)}</span>`;
513
+ })
514
+ .join('');
515
+ const allowed = resolved
516
+ ? `<div class="ldap-browser-entry__allowed">
517
+ <span><strong>Required:</strong> ${escapeHtml(resolved.must.join(', ') || '—')}</span>
518
+ <span><strong>Allowed:</strong> ${escapeHtml(resolved.may.join(', ') || '—')}</span>
519
+ </div>`
520
+ : '';
521
+ return `<div class="ldap-browser-entry__classes">${chips}</div>${allowed}`;
522
+ }
523
+ /**
524
+ * Render one attribute table.
525
+ *
526
+ * @param entry entry being displayed
527
+ * @param mandatory lowercase names of the mandatory attributes
528
+ * @param names attributes to include, in display order
529
+ * @param title heading of the table
530
+ * @returns HTML of the table
531
+ */
532
+ renderTable(entry, mandatory, names, title) {
533
+ if (names.length === 0)
534
+ return '';
535
+ const rows = names
536
+ .map(name => {
537
+ const attribute = entry.attributes[name];
538
+ const type = this.schema?.getAttributeType(name);
539
+ const isMandatory = mandatory.has(name.split(';')[0].toLowerCase());
540
+ const tooltip = type
541
+ ? [
542
+ type.desc,
543
+ type.syntax ? `syntax ${type.syntax}` : '',
544
+ type.singleValue ? 'single-valued' : '',
545
+ type.noUserModification ? 'read-only' : '',
546
+ ]
547
+ .filter(Boolean)
548
+ .join(' — ')
549
+ : 'unknown in schema';
550
+ return `<tr>
551
+ <th class="${isMandatory ? 'ldap-browser-attr--must' : ''}${type ? '' : ' ldap-browser-attr--unknown'}" title="${escapeHtml(tooltip)}">
552
+ ${escapeHtml(name)}${isMandatory ? ' *' : ''}
553
+ </th>
554
+ <td>${this.renderValues(name, attribute)}</td>
555
+ </tr>`;
556
+ })
557
+ .join('');
558
+ return `<h3 class="ldap-browser-entry__section">${escapeHtml(title)}</h3>
559
+ <table class="ldap-browser-entry__table"><tbody>${rows}</tbody></table>`;
560
+ }
561
+ /**
562
+ * Render the values of one attribute: images as thumbnails, other binary
563
+ * values as their size plus the base64 payload, text as text.
564
+ *
565
+ * @param name attribute name
566
+ * @param attribute values and binary flag
567
+ * @returns HTML of the value cell
568
+ */
569
+ renderValues(name, attribute) {
570
+ return attribute.values
571
+ .map(value => {
572
+ if (attribute.binary && this.schema?.isImageAttribute(name))
573
+ // Keep only base64 characters: the value ends up in an attribute
574
+ return `<img class="ldap-browser-entry__photo" alt="${escapeHtml(name)}" src="data:image/jpeg;base64,${value.replace(/[^A-Za-z0-9+/=]/g, '')}" />`;
575
+ if (attribute.binary)
576
+ return `<details class="ldap-browser-entry__binary">
577
+ <summary>${byteLength(value)} bytes (base64)</summary>
578
+ <code>${escapeHtml(value)}</code>
579
+ </details>`;
580
+ return `<div class="ldap-browser-entry__value">${escapeHtml(value)}</div>`;
581
+ })
582
+ .join('');
583
+ }
584
+ }
585
+ /**
586
+ * Decoded size of a base64 payload, without decoding it.
587
+ *
588
+ * @param base64 base64-encoded value
589
+ * @returns number of octets
590
+ */
591
+ function byteLength(base64) {
592
+ const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
593
+ return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
594
+ }
595
+
596
+ /**
597
+ * Client-side view of the directory schema: the lookups the UI needs to
598
+ * annotate an entry (mandatory attributes, descriptions, binary values).
599
+ *
600
+ * This mirrors `src/lib/ldapSchema.ts` on the server, minus the parsing:
601
+ * the browser receives the schema already parsed.
602
+ *
603
+ * @module browser/ldap-browser/schema
604
+ */
605
+ /** Syntaxes whose values are octets rather than text */
606
+ const BINARY_SYNTAXES = new Set([
607
+ '1.3.6.1.4.1.1466.115.121.1.5',
608
+ '1.3.6.1.4.1.1466.115.121.1.8',
609
+ '1.3.6.1.4.1.1466.115.121.1.9',
610
+ '1.3.6.1.4.1.1466.115.121.1.10',
611
+ '1.3.6.1.4.1.1466.115.121.1.28',
612
+ '1.3.6.1.4.1.1466.115.121.1.40',
613
+ '1.3.6.1.4.1.1466.115.121.1.49',
614
+ ]);
615
+ /** Attributes whose values are images, previewed as such */
616
+ const IMAGE_ATTRIBUTES = new Set([
617
+ 'jpegphoto',
618
+ 'photo',
619
+ 'thumbnailphoto',
620
+ 'thumbnaillogo',
621
+ ]);
622
+ class SchemaView {
623
+ constructor(schema) {
624
+ this.objectClasses = new Map();
625
+ this.attributeTypes = new Map();
626
+ this.schema = schema;
627
+ for (const oc of schema.objectClasses) {
628
+ this.objectClasses.set(oc.oid.toLowerCase(), oc);
629
+ for (const name of oc.names)
630
+ this.objectClasses.set(name.toLowerCase(), oc);
631
+ }
632
+ for (const at of schema.attributeTypes) {
633
+ this.attributeTypes.set(at.oid.toLowerCase(), at);
634
+ for (const name of at.names)
635
+ this.attributeTypes.set(name.toLowerCase(), at);
636
+ }
637
+ }
638
+ /**
639
+ * Look up an object class by name or OID.
640
+ *
641
+ * @param name object class name or OID
642
+ * @returns the definition, or undefined when unknown
643
+ */
644
+ getObjectClass(name) {
645
+ return this.objectClasses.get(name.toLowerCase());
646
+ }
647
+ /**
648
+ * Look up an attribute type by name or OID, ignoring attribute options.
649
+ *
650
+ * @param name attribute name, `;binary` and friends included
651
+ * @returns the definition, or undefined when unknown
652
+ */
653
+ getAttributeType(name) {
654
+ return this.attributeTypes.get(name.split(';')[0].toLowerCase());
655
+ }
656
+ /**
657
+ * Resolve the syntax of an attribute through its `SUP` chain.
658
+ *
659
+ * @param name attribute name
660
+ * @returns syntax OID, or undefined
661
+ */
662
+ getAttributeSyntax(name) {
663
+ const seen = new Set();
664
+ let current = this.getAttributeType(name);
665
+ while (current && !seen.has(current.oid)) {
666
+ if (current.syntax)
667
+ return current.syntax;
668
+ seen.add(current.oid);
669
+ current = current.sup ? this.getAttributeType(current.sup) : undefined;
670
+ }
671
+ return undefined;
672
+ }
673
+ /**
674
+ * Tell whether an attribute is meant to be displayed as an image.
675
+ *
676
+ * @param name attribute name
677
+ * @returns true for photo-like attributes
678
+ */
679
+ isImageAttribute(name) {
680
+ return IMAGE_ATTRIBUTES.has(name.split(';')[0].toLowerCase());
681
+ }
682
+ /**
683
+ * Tell whether an attribute holds octets rather than text.
684
+ *
685
+ * @param name attribute name
686
+ * @returns true when values are binary
687
+ */
688
+ isBinaryAttribute(name) {
689
+ if (name
690
+ .split(';')
691
+ .slice(1)
692
+ .some(o => o.toLowerCase() === 'binary'))
693
+ return true;
694
+ const syntax = this.getAttributeSyntax(name);
695
+ return syntax ? BINARY_SYNTAXES.has(syntax) : false;
696
+ }
697
+ /**
698
+ * Collect the mandatory and optional attributes of a set of object
699
+ * classes, walking every `SUP` chain.
700
+ *
701
+ * @param objectClasses object class names carried by an entry
702
+ * @returns `must` and `may` attribute names, `may` excluding mandatory ones
703
+ */
704
+ resolveAttributes(objectClasses) {
705
+ const must = new Set();
706
+ const may = new Set();
707
+ const visited = new Set();
708
+ const walk = (name) => {
709
+ const key = name.toLowerCase();
710
+ if (visited.has(key))
711
+ return;
712
+ visited.add(key);
713
+ const oc = this.getObjectClass(name);
714
+ if (!oc)
715
+ return;
716
+ oc.must.forEach(a => must.add(a));
717
+ oc.may.forEach(a => may.add(a));
718
+ oc.sup.forEach(walk);
719
+ };
720
+ objectClasses.forEach(walk);
721
+ const mustLower = new Set([...must].map(a => a.toLowerCase()));
722
+ return {
723
+ must: [...must],
724
+ may: [...may].filter(a => !mustLower.has(a.toLowerCase())),
725
+ };
726
+ }
727
+ }
728
+
729
+ /**
730
+ * Low-level LDAP browser: a tree of the directory on the left, the raw
731
+ * content of the selected entry on the right, and a filter search on top.
732
+ *
733
+ * Read-only: it renders what the `core/ldap/raw` plugin serves.
734
+ *
735
+ * @module browser/ldap-browser/LdapBrowser
736
+ */
737
+ class LdapBrowser {
738
+ constructor(options) {
739
+ this.container = null;
740
+ this.tree = null;
741
+ this.entryView = null;
742
+ this.schema = null;
743
+ this.bases = [];
744
+ this.currentDn = null;
745
+ this.options = options;
746
+ this.api = new RawApiClient(options.apiBaseUrl || '', options.authToken);
747
+ }
748
+ /**
749
+ * Build the interface, load the schema and open the first base.
750
+ *
751
+ * @throws Error when the container does not exist or the API is unreachable
752
+ */
753
+ async init() {
754
+ const container = document.getElementById(this.options.containerId);
755
+ if (!container)
756
+ throw new Error(`Container element with id '${this.options.containerId}' not found`);
757
+ this.container = container;
758
+ this.bases = await this.api.getBases();
759
+ this.render();
760
+ // The schema only decorates the display: a directory that refuses it
761
+ // must not prevent browsing
762
+ try {
763
+ this.schema = new SchemaView(await this.api.getSchema());
764
+ }
765
+ catch (err) {
766
+ this.reportError(err);
767
+ }
768
+ const treeContainer = container.querySelector('.ldap-browser__tree');
769
+ const entryContainer = container.querySelector('.ldap-browser__entry');
770
+ if (!treeContainer || !entryContainer)
771
+ return;
772
+ this.entryView = new EntryView(entryContainer, this.schema);
773
+ this.entryView.renderEmpty();
774
+ this.tree = new EntryTree(treeContainer, this.api, dn => this.showEntry(dn), error => this.reportError(error));
775
+ await this.tree.init(this.bases);
776
+ this.bindToolbar();
777
+ const initial = this.options.initialDn || this.bases[0];
778
+ if (initial)
779
+ await this.tree.revealAndSelect(initial);
780
+ }
781
+ /** Release the listeners held by the tree */
782
+ destroy() {
783
+ this.tree?.destroy();
784
+ this.tree = null;
785
+ }
786
+ /**
787
+ * DN currently displayed.
788
+ *
789
+ * @returns selected DN, or null when none
790
+ */
791
+ getCurrentDn() {
792
+ return this.currentDn;
793
+ }
794
+ /** Draw the static layout */
795
+ render() {
796
+ if (!this.container)
797
+ return;
798
+ this.container.classList.add('ldap-browser-container');
799
+ this.container.innerHTML = `
800
+ <div class="ldap-browser">
801
+ <div class="ldap-browser__toolbar">
802
+ <input class="ldap-browser__filter" type="text"
803
+ placeholder="LDAP filter, e.g. (uid=alice)" />
804
+ <select class="ldap-browser__scope">
805
+ <option value="sub">sub</option>
806
+ <option value="one">one</option>
807
+ <option value="base">base</option>
808
+ </select>
809
+ <select class="ldap-browser__base">
810
+ ${this.bases
811
+ .map(base => `<option value="${escapeHtml(base)}">${escapeHtml(base)}</option>`)
812
+ .join('')}
813
+ </select>
814
+ <button class="ldap-browser__search" type="button">
815
+ <span class="material-icons">search</span> Search
816
+ </button>
817
+ <button class="ldap-browser__refresh" type="button" title="Reload">
818
+ <span class="material-icons">refresh</span>
819
+ </button>
820
+ </div>
821
+ <div class="ldap-browser__body">
822
+ <div class="ldap-browser__tree"></div>
823
+ <div class="ldap-browser__entry"></div>
824
+ </div>
825
+ <div class="ldap-browser__results" hidden></div>
826
+ </div>`;
827
+ }
828
+ /** Wire the search and refresh controls */
829
+ bindToolbar() {
830
+ const filter = this.container?.querySelector('.ldap-browser__filter');
831
+ const searchButton = this.container?.querySelector('.ldap-browser__search');
832
+ const refreshButton = this.container?.querySelector('.ldap-browser__refresh');
833
+ const results = this.container?.querySelector('.ldap-browser__results');
834
+ searchButton?.addEventListener('click', () => void this.runSearch());
835
+ filter?.addEventListener('keydown', event => {
836
+ if (event.key === 'Enter')
837
+ void this.runSearch();
838
+ });
839
+ refreshButton?.addEventListener('click', () => {
840
+ this.tree?.invalidate();
841
+ if (this.currentDn)
842
+ void this.showEntry(this.currentDn);
843
+ });
844
+ results?.addEventListener('click', event => {
845
+ const target = event.target.closest('[data-dn]');
846
+ if (target?.dataset.dn)
847
+ void this.tree?.revealAndSelect(target.dataset.dn);
848
+ });
849
+ }
850
+ /**
851
+ * Load an entry and display it.
852
+ *
853
+ * @param dn DN of the entry
854
+ */
855
+ async showEntry(dn) {
856
+ if (!this.entryView)
857
+ return;
858
+ this.currentDn = dn;
859
+ this.entryView.renderLoading();
860
+ try {
861
+ const entry = await this.api.getEntry(dn);
862
+ this.entryView.render(entry);
863
+ this.options.onEntrySelected?.(dn);
864
+ }
865
+ catch (err) {
866
+ this.entryView.renderError(err.message);
867
+ this.reportError(err);
868
+ }
869
+ }
870
+ /** Run the search typed in the toolbar and list the matching DNs */
871
+ async runSearch() {
872
+ const filter = this.container?.querySelector('.ldap-browser__filter')
873
+ ?.value || '';
874
+ const scope = this.container?.querySelector('.ldap-browser__scope')
875
+ ?.value || 'sub';
876
+ const base = this.container?.querySelector('.ldap-browser__base')
877
+ ?.value || this.bases[0];
878
+ const results = this.container?.querySelector('.ldap-browser__results');
879
+ if (!results)
880
+ return;
881
+ if (!filter.trim()) {
882
+ results.hidden = true;
883
+ results.innerHTML = '';
884
+ return;
885
+ }
886
+ results.hidden = false;
887
+ results.innerHTML = '<p>Searching…</p>';
888
+ try {
889
+ const found = await this.api.search({
890
+ base,
891
+ scope: scope,
892
+ filter,
893
+ attributes: ['dn'],
894
+ });
895
+ results.innerHTML = found.entries.length
896
+ ? `<p>${found.entries.length} result(s)${found.truncated ? ' (truncated)' : ''}</p>
897
+ <ul>${found.entries
898
+ .map(entry => `<li><a href="#" data-dn="${escapeHtml(entry.dn)}">${escapeHtml(entry.dn)}</a></li>`)
899
+ .join('')}</ul>`
900
+ : '<p>No result</p>';
901
+ }
902
+ catch (err) {
903
+ results.innerHTML = `<p class="ldap-browser__error">${escapeHtml(err.message)}</p>`;
904
+ this.reportError(err);
905
+ }
906
+ }
907
+ /**
908
+ * Forward an error to the host page, or log it when no handler was given.
909
+ *
910
+ * @param error error to report
911
+ */
912
+ reportError(error) {
913
+ if (this.options.onError)
914
+ this.options.onError(error);
915
+ else
916
+ console.error('[ldap-browser]', error);
917
+ }
918
+ }
919
+
920
+ exports.LdapBrowser = LdapBrowser;
921
+ exports.RawApiClient = RawApiClient;
922
+ exports.SchemaView = SchemaView;
923
+
924
+ }));
925
+ //# sourceMappingURL=ldap-browser.js.map