@mmlogic/components 0.1.7 → 0.1.8

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.
@@ -0,0 +1,521 @@
1
+ /* =====================================================================
2
+ APP STATE
3
+ ===================================================================== */
4
+
5
+ let _selectedTenant = null;
6
+ let _selectedType = null; // { name, pluralName }
7
+ let _relationMeta = {}; // relatedClass / mostSignificantClass → { name, mostSignificantClass }
8
+ let _tableDataHref = null; // base href for the current table view (without ?page=)
9
+
10
+ /* =====================================================================
11
+ UTILITIES
12
+ ===================================================================== */
13
+
14
+ function escHtml(str) {
15
+ if (str == null) return '';
16
+ return String(str)
17
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
19
+ }
20
+
21
+ function httpStatusText(code) {
22
+ const map = {
23
+ 200: 'OK', 201: 'Created', 204: 'No Content',
24
+ 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden',
25
+ 404: 'Not Found', 409: 'Conflict', 422: 'Unprocessable Entity',
26
+ 500: 'Internal Server Error',
27
+ };
28
+ return map[code] || '';
29
+ }
30
+
31
+ function logEvent(type, data) {
32
+ document.getElementById('event-log').textContent =
33
+ '[' + type + ']\n' + JSON.stringify(data, null, 2);
34
+ }
35
+
36
+ /* =====================================================================
37
+ TAB SWITCHING
38
+ ===================================================================== */
39
+
40
+ function showTab(name) {
41
+ document.querySelectorAll('.tab-btn').forEach((btn, i) => {
42
+ const names = ['embedded', 'live-api'];
43
+ btn.classList.toggle('active', names[i] === name);
44
+ });
45
+ document.getElementById('tab-embedded').classList.toggle('active', name === 'embedded');
46
+ document.getElementById('tab-live-api').classList.toggle('active', name === 'live-api');
47
+ }
48
+
49
+ /* =====================================================================
50
+ TENANT SELECTION
51
+ ===================================================================== */
52
+
53
+ async function loadTenants() {
54
+ const panel = document.getElementById('panel-tenant');
55
+ panel.classList.remove('hidden');
56
+ const grid = document.getElementById('tenant-grid');
57
+ grid.innerHTML = '<span class="spinner"></span>';
58
+
59
+ try {
60
+ const tenants = await apiFetchTenants(authGetToken());
61
+ renderTenants(tenants);
62
+ } catch (err) {
63
+ grid.innerHTML = `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(err.message)}</span>`;
64
+ }
65
+ }
66
+
67
+ function renderTenants(tenants) {
68
+ const grid = document.getElementById('tenant-grid');
69
+ if (!tenants || tenants.length === 0) {
70
+ grid.innerHTML = '<em style="color:var(--mrd-color-neutral-500)">Geen tenants gevonden.</em>';
71
+ return;
72
+ }
73
+
74
+ grid.innerHTML = tenants.map((t, i) => {
75
+ const code = t.tenantCode || t.code || t.id || String(i);
76
+ const name = t.name || code;
77
+ const desc = t.description || '';
78
+ return `
79
+ <div class="tenant-card">
80
+ <input type="radio" name="tenant" id="tenant-${escHtml(code)}" value="${escHtml(code)}"
81
+ onchange="onTenantChange('${escHtml(code)}', '${escHtml(name)}')">
82
+ <label for="tenant-${escHtml(code)}">
83
+ ${escHtml(name)}
84
+ ${desc ? `<small>${escHtml(desc)}</small>` : ''}
85
+ </label>
86
+ </div>`;
87
+ }).join('');
88
+
89
+ const savedTenant = localStorage.getItem('last_tenant');
90
+ if (savedTenant) {
91
+ const radio = document.getElementById(`tenant-${savedTenant}`);
92
+ if (radio) {
93
+ radio.checked = true;
94
+ const label = document.querySelector(`label[for="tenant-${savedTenant}"]`);
95
+ const name = label ? (label.firstChild?.textContent?.trim() || savedTenant) : savedTenant;
96
+ onTenantChange(savedTenant, name);
97
+ }
98
+ }
99
+ }
100
+
101
+ async function onTenantChange(code, name) {
102
+ _selectedTenant = code;
103
+ localStorage.setItem('last_tenant', code);
104
+ _selectedType = null;
105
+ document.getElementById('panel-table').classList.add('hidden');
106
+ document.getElementById('panel-form').classList.add('hidden');
107
+ document.getElementById('panel-response').innerHTML = '';
108
+
109
+ const typePanel = document.getElementById('panel-type');
110
+ typePanel.classList.remove('hidden');
111
+ const sel = document.getElementById('type-select');
112
+ sel.innerHTML = '<option value="">⏳ laden...</option>';
113
+ sel.disabled = true;
114
+
115
+ try {
116
+ const types = await apiFetchTypes(authGetToken(), code);
117
+ renderTypes(types);
118
+ } catch (err) {
119
+ sel.innerHTML = '<option value="">— fout bij laden —</option>';
120
+ sel.disabled = false;
121
+ typePanel.querySelector('h3').insertAdjacentHTML('afterend',
122
+ `<p style="color:var(--mrd-color-danger);font-size:.875rem;margin:0 0 .75rem">❌ ${escHtml(err.message)}</p>`);
123
+ }
124
+ }
125
+
126
+ function renderTypes(types) {
127
+ const sel = document.getElementById('type-select');
128
+ if (!types || types.length === 0) {
129
+ sel.innerHTML = '<option value="">— geen types gevonden —</option>';
130
+ sel.disabled = true;
131
+ return;
132
+ }
133
+ sel.innerHTML = '<option value="">— selecteer type —</option>' +
134
+ types.map(t => {
135
+ const plural = t.pluralName || t.name;
136
+ return `<option value="${escHtml(plural)}" data-name="${escHtml(t.name)}">${escHtml(t.name)}</option>`;
137
+ }).join('');
138
+ sel.disabled = false;
139
+ }
140
+
141
+ /* =====================================================================
142
+ TABLE
143
+ ===================================================================== */
144
+
145
+ async function loadTable() {
146
+ const sel = document.getElementById('type-select');
147
+ const pluralName = sel.value;
148
+ if (!pluralName) return;
149
+
150
+ const opt = sel.options[sel.selectedIndex];
151
+ _selectedType = { name: opt.dataset.name || pluralName, pluralName };
152
+
153
+ const tablePanel = document.getElementById('panel-table');
154
+ tablePanel.classList.remove('hidden');
155
+ tablePanel.innerHTML = '<span class="spinner"></span> Lijst laden...';
156
+ document.getElementById('panel-form').classList.add('hidden');
157
+ document.getElementById('panel-response').innerHTML = '';
158
+ _tableDataHref = null;
159
+
160
+ try {
161
+ const dashboard = await apiFetchDashboard(authGetToken(), _selectedTenant, pluralName);
162
+
163
+ const viewKey = dashboard.layouts?.[0]?.items?.[0]?.name;
164
+ const view = viewKey && dashboard.views?.[viewKey];
165
+ const dataHref = viewKey && dashboard._links?.[viewKey]?.href;
166
+ if (!view || !dataHref) throw new Error('Geen geldige view gevonden in dashboard response.');
167
+
168
+ // Strip any ?page= from href so we control pagination ourselves
169
+ _tableDataHref = dataHref.replace(/([?&])page=\d+/, '').replace(/\?$/, '');
170
+
171
+ const columns = (view.values ?? []).map(mapApiItem);
172
+ const defaultSort = view.defaultSort ?? '';
173
+
174
+ // Fetch page 0 for totalElements + first rows (including defaultSort)
175
+ const page0 = await apiFetchPage(authGetToken(), _tableDataHref, 0, defaultSort);
176
+ const meta = page0.page ?? {};
177
+ const total = meta.totalElements ?? 0;
178
+ const pageSize = meta.size ?? 20;
179
+ const embedded = page0._embedded ?? {};
180
+ const rows0 = Object.values(embedded)[0] ?? [];
181
+
182
+ renderTable(columns, total, pageSize, rows0, _tableDataHref, defaultSort);
183
+ } catch (err) {
184
+ tablePanel.innerHTML = `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(err.message)}</span>`;
185
+ }
186
+ }
187
+
188
+ function renderTable(columns, totalElements, pageSize, page0Rows, dataHref, defaultSort = '') {
189
+ const tablePanel = document.getElementById('panel-table');
190
+ tablePanel.innerHTML = '<mrd-table id="live-table"></mrd-table>';
191
+
192
+ customElements.whenDefined('mrd-table').then(async () => {
193
+ const table = document.getElementById('live-table');
194
+
195
+ table.columns = columns;
196
+ table.locale = 'nl-NL';
197
+ table.pageSize = pageSize;
198
+ table.tableHeight = 500;
199
+ table.totalElements = totalElements;
200
+ table.defaultSort = defaultSort;
201
+
202
+ // Register mrdLoadPage before init() so scroll events are caught immediately
203
+ table.addEventListener('mrdLoadPage', async (e) => {
204
+ const { page, sort } = e.detail;
205
+ try {
206
+ const result = await apiFetchPage(authGetToken(), dataHref, page, sort);
207
+ const embedded = result._embedded ?? {};
208
+ const rows = Object.values(embedded)[0] ?? [];
209
+ await table.setPage(page, rows);
210
+ } catch (err) {
211
+ console.error('[mrd-table] pagina laden mislukt', page, err);
212
+ }
213
+ });
214
+
215
+ // Open form with existing record on row click
216
+ table.addEventListener('mrdRowClick', async (e) => {
217
+ await loadRecord(e.detail);
218
+ });
219
+
220
+ await table.init();
221
+ await table.setPage(0, page0Rows); // inject pre-fetched page 0 — no extra request
222
+ });
223
+ }
224
+
225
+ /* =====================================================================
226
+ FORM — LOAD & RENDER
227
+ ===================================================================== */
228
+
229
+ /**
230
+ * Load an existing record by clicking a table row.
231
+ * Fetches the record (GET _links.self.href) and the form layout in parallel,
232
+ * then opens the form pre-filled and in PATCH mode.
233
+ */
234
+ async function loadRecord(row) {
235
+ const selfHref = row?._links?.self?.href;
236
+ if (!selfHref) { console.warn('[loadRecord] geen _links.self.href in rij', row); return; }
237
+
238
+ const sel = document.getElementById('type-select');
239
+ if (!sel.value) return;
240
+
241
+ const formPanel = document.getElementById('panel-form');
242
+ formPanel.classList.remove('hidden');
243
+ formPanel.innerHTML = '<span class="spinner"></span> Record laden...';
244
+ document.getElementById('panel-table').classList.add('hidden');
245
+ document.getElementById('panel-response').innerHTML = '';
246
+
247
+ try {
248
+ // Fetch the record first so we can read _links.form.href for the exact layout URL
249
+ const recordResp = await apiRequest('GET', selfHref, authGetToken());
250
+ if (!recordResp.ok) throw new Error(`${recordResp.status}: kon record niet ophalen`);
251
+
252
+ const formHref = recordResp.body?._links?.form?.href ?? null;
253
+ const layout = await apiFetchForm(authGetToken(), _selectedTenant, _selectedType.pluralName, formHref);
254
+ renderForm(layout, recordResp.body, selfHref);
255
+ } catch (err) {
256
+ formPanel.innerHTML = `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(err.message)}</span>`;
257
+ }
258
+ }
259
+
260
+ async function loadForm() {
261
+ const sel = document.getElementById('type-select');
262
+ const pluralName = sel.value;
263
+ if (!pluralName) return;
264
+
265
+ const opt = sel.options[sel.selectedIndex];
266
+ _selectedType = { name: opt.dataset.name || pluralName, pluralName };
267
+
268
+ const formPanel = document.getElementById('panel-form');
269
+ formPanel.classList.remove('hidden');
270
+ formPanel.innerHTML = '<span class="spinner"></span> Formulier laden...';
271
+ document.getElementById('panel-response').innerHTML = '';
272
+
273
+ try {
274
+ const layout = await apiFetchForm(authGetToken(), _selectedTenant, pluralName);
275
+ renderForm(layout);
276
+ } catch (err) {
277
+ formPanel.innerHTML = `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(err.message)}</span>`;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Mount mrd-form and wire up all event handlers.
283
+ *
284
+ * @param layout - mrd-form layout descriptor
285
+ * @param record - existing record for edit mode (null = new record)
286
+ * @param selfHref - absolute URL for PATCH; null = POST (new record)
287
+ */
288
+ function renderForm(layout, record = null, selfHref = null) {
289
+ // Build relation metadata map keyed by both relatedClass and mostSignificantClass
290
+ // so lookups work regardless of which value arrives in events (mrdSearch uses mostSignificantClass).
291
+ _relationMeta = {};
292
+ function collectRelations(items) {
293
+ if (!Array.isArray(items)) return;
294
+ items.forEach(item => {
295
+ if (item.type === 'RELATION' && item.relation) {
296
+ const meta = {
297
+ name: item.relation.name,
298
+ mostSignificantClass: item.relation.mostSignificantClass,
299
+ };
300
+ _relationMeta[item.relation.relatedClass] = meta;
301
+ if (item.relation.mostSignificantClass) {
302
+ _relationMeta[item.relation.mostSignificantClass] = meta;
303
+ }
304
+ }
305
+ if (Array.isArray(item.items)) collectRelations(item.items);
306
+ });
307
+ }
308
+ collectRelations(layout.items);
309
+
310
+ // Build initial form values from the fetched record (edit mode)
311
+ const relationFieldNames = new Set(Object.values(_relationMeta).map(m => m.name));
312
+ let initialValues = {};
313
+ if (record) {
314
+ for (const [key, val] of Object.entries(record)) {
315
+ if (key === '_links' || key === '_embedded') continue;
316
+ initialValues[key] = val;
317
+ }
318
+ // Relation fields: convert _links entries to { id, label } for mrd-relation-field pre-fill
319
+ for (const relName of relationFieldNames) {
320
+ const link = record._links?.[relName];
321
+ if (!link) continue;
322
+ if (Array.isArray(link)) {
323
+ initialValues[relName] = link.map(l => ({ id: l.href, label: l.name ?? '' }));
324
+ } else if (link.href) {
325
+ initialValues[relName] = { id: link.href, label: link.name ?? '' };
326
+ }
327
+ }
328
+ }
329
+
330
+ const formPanel = document.getElementById('panel-form');
331
+ formPanel.innerHTML = '<mrd-form id="live-form" locale="nl-NL"></mrd-form>';
332
+
333
+ customElements.whenDefined('mrd-form').then(() => {
334
+ const form = document.getElementById('live-form');
335
+ form.layout = layout;
336
+ if (record) form.values = initialValues;
337
+
338
+ // Upload files immediately on selection; write binary URI back via setFieldValue
339
+ form.addEventListener('mrdChange', async (e) => {
340
+ const { name, value } = e.detail;
341
+ if (!(value instanceof File)) return;
342
+ try {
343
+ const uri = await apiUploadFile(authGetToken(), _selectedTenant, value);
344
+ await form.setFieldValue(name, uri);
345
+ } catch (err) {
346
+ console.error(`[upload] mislukt voor veld "${name}":`, err);
347
+ }
348
+ });
349
+
350
+ form.addEventListener('mrdSubmit', async (e) => {
351
+ document.getElementById('panel-response').innerHTML =
352
+ '<div class="response-card" style="background:var(--mrd-color-neutral-100);border-color:var(--mrd-color-neutral-300);max-width:860px"><span class="spinner"></span> Bezig met indienen...</div>';
353
+
354
+ // Transform relation values: { id: href, label } → href string
355
+ const transformValues = (source) => {
356
+ const out = {};
357
+ for (const [key, val] of Object.entries(source)) {
358
+ if (relationFieldNames.has(key)) {
359
+ if (Array.isArray(val)) {
360
+ out[key] = val.map(v => (v && typeof v === 'object' ? v.id : v));
361
+ } else if (val && typeof val === 'object' && val.id) {
362
+ out[key] = val.id;
363
+ } else {
364
+ out[key] = val;
365
+ }
366
+ } else {
367
+ out[key] = val;
368
+ }
369
+ }
370
+ return out;
371
+ };
372
+
373
+ if (selfHref) {
374
+ // PATCH mode: send only changed fields
375
+ const submitted = transformValues(e.detail);
376
+ const original = transformValues(initialValues);
377
+ const patch = {};
378
+ for (const [key, val] of Object.entries(submitted)) {
379
+ if (JSON.stringify(val) !== JSON.stringify(original[key])) {
380
+ patch[key] = val;
381
+ }
382
+ }
383
+ if (Object.keys(patch).length === 0) {
384
+ renderResponse(200, 'Geen wijzigingen.');
385
+ return;
386
+ }
387
+ const result = await apiRequest('PATCH', selfHref, authGetToken(), patch);
388
+ renderResponse(result.status, result.body);
389
+ } else {
390
+ // POST mode: new record
391
+ const values = transformValues(e.detail);
392
+ const result = await apiSubmitForm(authGetToken(), _selectedTenant, _selectedType.pluralName, values);
393
+ renderResponse(result.status, result.body);
394
+ }
395
+ });
396
+
397
+ form.addEventListener('mrdSearch', async (e) => {
398
+ logEvent('mrdSearch (live)', e.detail);
399
+ const { query, relatedClass } = e.detail;
400
+ const meta = _relationMeta[relatedClass];
401
+ if (!meta || !query || query.length < 2) return;
402
+
403
+ try {
404
+ const results = await apiSearchRelation(authGetToken(), _selectedTenant, meta.mostSignificantClass, query);
405
+ const host = Array.from(document.querySelectorAll('mrd-relation-field'))
406
+ .find(el => el.name === meta.name);
407
+ if (host && typeof host.setSearchResults === 'function') {
408
+ host.setSearchResults(results);
409
+ }
410
+ } catch (err) {
411
+ console.error('[mrdSearch] relation search failed:', err);
412
+ }
413
+ });
414
+
415
+ form.addEventListener('mrdFetchAll', async (e) => {
416
+ logEvent('mrdFetchAll (live)', e.detail);
417
+ const { name, mostSignificantClass, filter, filterValue } = e.detail;
418
+ if (!mostSignificantClass) return;
419
+
420
+ const host = Array.from(document.querySelectorAll('mrd-relation-field'))
421
+ .find(el => el.name === name);
422
+ if (!host || typeof host.setAllRecords !== 'function') return;
423
+
424
+ // If a filter is required but no value is set yet, clear the dropdown
425
+ if (filter && !filterValue) {
426
+ host.setAllRecords([]);
427
+ return;
428
+ }
429
+
430
+ try {
431
+ // Build URL: /data/{tenant}/{mostSignificantClass}?{filter}={filterValue}&page=0
432
+ let baseHref = `/data/${_selectedTenant}/${mostSignificantClass}`;
433
+ if (filter && filterValue) baseHref += `?${encodeURIComponent(filter)}=${encodeURIComponent(filterValue)}`;
434
+ const result = await apiFetchPage(authGetToken(), baseHref, 0);
435
+ const embedded = result._embedded ?? {};
436
+ const records = Object.values(embedded)[0] ?? [];
437
+ host.setAllRecords(records.map(r => ({ id: r._links?.self?.href ?? r.id, label: r.name })));
438
+ } catch (err) {
439
+ console.error('[mrdFetchAll] failed:', err);
440
+ }
441
+ });
442
+ });
443
+ }
444
+
445
+ /* =====================================================================
446
+ RESPONSE RENDERING
447
+ ===================================================================== */
448
+
449
+ function renderResponse(status, body) {
450
+ const isOk = status >= 200 && status < 300;
451
+ const cls = isOk ? 'success' : 'error';
452
+ const icon = isOk ? '✅' : '❌';
453
+ const label = httpStatusText(status);
454
+
455
+ let inner = '';
456
+
457
+ if (!isOk && body && typeof body === 'object') {
458
+ const msg = body.message || body.error || '';
459
+ const trace = body.trace || body.stackTrace || '';
460
+ inner = `
461
+ <h4>${icon} ${status} ${escHtml(label)}</h4>
462
+ ${msg ? `<p style="margin:.25rem 0;font-size:.875rem">${escHtml(msg)}</p>` : ''}
463
+ ${trace ? `<details><summary>trace</summary><pre>${escHtml(trace)}</pre></details>` : ''}
464
+ ${!msg && !trace ? `<pre>${escHtml(JSON.stringify(body, null, 2))}</pre>` : ''}`;
465
+ } else {
466
+ const text = typeof body === 'string' ? body : JSON.stringify(body, null, 2);
467
+ inner = `
468
+ <h4>${icon} ${status} ${escHtml(label)}</h4>
469
+ ${text ? `<pre>${escHtml(text)}</pre>` : ''}`;
470
+ }
471
+
472
+ document.getElementById('panel-response').innerHTML =
473
+ `<div class="response-card ${cls}">${inner}</div>`;
474
+ }
475
+
476
+ /* =====================================================================
477
+ EMBEDDED FORM (demo tab)
478
+ ===================================================================== */
479
+
480
+ customElements.whenDefined('mrd-form').then(() => {
481
+ const form = document.getElementById('demo-form');
482
+ form.layout = window.EXAMPLE_LAYOUT;
483
+ form.values = window.EXAMPLE_VALUES;
484
+
485
+ form.addEventListener('mrdSubmit', (e) => { console.log('[mrdSubmit]', e.detail); logEvent('mrdSubmit', e.detail); });
486
+ form.addEventListener('mrdSearch', (e) => { console.log('[mrdSearch]', e.detail); logEvent('mrdSearch', e.detail); });
487
+ form.addEventListener('mrdFetchAll', (e) => { console.log('[mrdFetchAll]', e.detail); logEvent('mrdFetchAll', e.detail); });
488
+ });
489
+
490
+ document.getElementById('locale-select').addEventListener('change', (e) => {
491
+ document.getElementById('demo-form').locale = e.target.value;
492
+ });
493
+
494
+ document.getElementById('btn-inject-results').addEventListener('click', () => {
495
+ const relationField = document.querySelector('mrd-relation-field');
496
+ if (relationField && typeof relationField.setSearchResults === 'function') {
497
+ relationField.setSearchResults([
498
+ { id: '1', label: 'Alice Johnson', description: 'Senior Engineer' },
499
+ { id: '2', label: 'Bob van der Berg', description: 'Product Manager' },
500
+ { id: '3', label: 'Carol Martínez', description: 'UX Designer' },
501
+ { id: '4', label: 'David Müller', description: 'DevOps Lead' },
502
+ ]);
503
+ } else {
504
+ logEvent('info', 'Type 2+ chars in the Project Manager field first, then click this');
505
+ }
506
+ });
507
+
508
+ /* =====================================================================
509
+ BOOT
510
+ ===================================================================== */
511
+
512
+ document.addEventListener('DOMContentLoaded', async () => {
513
+ authRestoreSession();
514
+ await authHandleCallback();
515
+
516
+ if (authGetToken()) {
517
+ showTab('live-api');
518
+ showAuthStatus();
519
+ loadTenants();
520
+ }
521
+ });
@@ -0,0 +1,156 @@
1
+ /* =====================================================================
2
+ AUTH0 PKCE
3
+ ===================================================================== */
4
+
5
+ const AUTH0_DOMAIN = 'login.develop.rulebooks.nl';
6
+ const AUTH0_CLIENT = '5zpghu11GLFrx95eg5jMl1f0BEnkIenF';
7
+ const AUTH0_AUDIENCE = 'https://api.mosterd';
8
+ const REDIRECT_URI = 'http://localhost:3333';
9
+
10
+ let _accessToken = null;
11
+ let _userEmail = null;
12
+
13
+ function authRestoreSession() {
14
+ const token = localStorage.getItem('auth_token');
15
+ const exp = parseInt(localStorage.getItem('auth_exp') || '0', 10);
16
+ if (!token || (exp && Date.now() / 1000 >= exp - 60)) {
17
+ localStorage.removeItem('auth_token');
18
+ localStorage.removeItem('auth_email');
19
+ localStorage.removeItem('auth_exp');
20
+ return;
21
+ }
22
+ _accessToken = token;
23
+ _userEmail = localStorage.getItem('auth_email') || null;
24
+ }
25
+
26
+ function base64url(buffer) {
27
+ return btoa(String.fromCharCode(...new Uint8Array(buffer)))
28
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
29
+ }
30
+
31
+ async function pkceGenerateVerifier() {
32
+ const bytes = crypto.getRandomValues(new Uint8Array(96));
33
+ return base64url(bytes);
34
+ }
35
+
36
+ async function pkceGenerateChallenge(verifier) {
37
+ const data = new TextEncoder().encode(verifier);
38
+ const hash = await crypto.subtle.digest('SHA-256', data);
39
+ return base64url(hash);
40
+ }
41
+
42
+ async function authLogin() {
43
+ const verifier = await pkceGenerateVerifier();
44
+ const challenge = await pkceGenerateChallenge(verifier);
45
+ sessionStorage.setItem('pkce_verifier', verifier);
46
+
47
+ const params = new URLSearchParams({
48
+ response_type: 'code',
49
+ client_id: AUTH0_CLIENT,
50
+ redirect_uri: REDIRECT_URI,
51
+ audience: AUTH0_AUDIENCE,
52
+ scope: 'openid profile email',
53
+ code_challenge: challenge,
54
+ code_challenge_method: 'S256',
55
+ });
56
+
57
+ window.location.href = `https://${AUTH0_DOMAIN}/authorize?${params}`;
58
+ }
59
+
60
+ async function authHandleCallback() {
61
+ const url = new URL(window.location.href);
62
+ const code = url.searchParams.get('code');
63
+ const error = url.searchParams.get('error');
64
+
65
+ if (error) {
66
+ showAuthError(url.searchParams.get('error_description') || error);
67
+ cleanUrl();
68
+ return;
69
+ }
70
+
71
+ if (!code) return;
72
+
73
+ const verifier = sessionStorage.getItem('pkce_verifier');
74
+ if (!verifier) {
75
+ showAuthError('PKCE verifier missing — please try logging in again.');
76
+ cleanUrl();
77
+ return;
78
+ }
79
+ sessionStorage.removeItem('pkce_verifier');
80
+
81
+ try {
82
+ const resp = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ body: JSON.stringify({
86
+ grant_type: 'authorization_code',
87
+ client_id: AUTH0_CLIENT,
88
+ code,
89
+ redirect_uri: REDIRECT_URI,
90
+ code_verifier: verifier,
91
+ }),
92
+ });
93
+
94
+ const data = await resp.json();
95
+ if (!resp.ok) throw new Error(data.error_description || data.error || resp.statusText);
96
+
97
+ _accessToken = data.access_token;
98
+
99
+ // Decode id_token for email (no signature validation needed on test page)
100
+ if (data.id_token) {
101
+ try {
102
+ const payload = JSON.parse(atob(data.id_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
103
+ _userEmail = payload.email || payload.name || null;
104
+ } catch (_) { /* ignore */ }
105
+ }
106
+
107
+ // Persist session in localStorage
108
+ try {
109
+ const payload = JSON.parse(atob(data.access_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
110
+ localStorage.setItem('auth_token', _accessToken);
111
+ localStorage.setItem('auth_email', _userEmail || '');
112
+ if (payload.exp) localStorage.setItem('auth_exp', String(payload.exp));
113
+ } catch (_) { /* ignore */ }
114
+ } catch (err) {
115
+ showAuthError(err.message);
116
+ }
117
+
118
+ cleanUrl();
119
+ }
120
+
121
+ function cleanUrl() {
122
+ const clean = window.location.pathname;
123
+ window.history.replaceState({}, document.title, clean);
124
+ }
125
+
126
+ function authGetToken() { return _accessToken; }
127
+
128
+ function showAuthError(msg) {
129
+ document.getElementById('auth-status-area').innerHTML =
130
+ `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(msg)}</span>
131
+ <button class="btn-secondary" onclick="authLogin()">Opnieuw proberen</button>`;
132
+ }
133
+
134
+ function showAuthStatus() {
135
+ const area = document.getElementById('auth-status-area');
136
+ const label = _userEmail ? `Ingelogd als <strong>${escHtml(_userEmail)}</strong>` : 'Ingelogd';
137
+ area.innerHTML =
138
+ `<span class="badge-ok">✓ ${label}</span>
139
+ <button class="btn-secondary" onclick="authLogout()">Uitloggen</button>`;
140
+ }
141
+
142
+ function authLogout() {
143
+ _accessToken = null;
144
+ _userEmail = null;
145
+ localStorage.removeItem('auth_token');
146
+ localStorage.removeItem('auth_email');
147
+ localStorage.removeItem('auth_exp');
148
+ localStorage.removeItem('last_tenant');
149
+ document.getElementById('auth-status-area').innerHTML =
150
+ `<button class="btn-primary" id="btn-login" onclick="authLogin()">Login met Auth0</button>`;
151
+ document.getElementById('panel-tenant').classList.add('hidden');
152
+ document.getElementById('panel-type').classList.add('hidden');
153
+ document.getElementById('panel-table').classList.add('hidden');
154
+ document.getElementById('panel-form').classList.add('hidden');
155
+ document.getElementById('panel-response').innerHTML = '';
156
+ }