@sasjs/cli 6.30.1 → 6.32.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.
@@ -0,0 +1,797 @@
1
+ /**
2
+ * The API console for a browser-hosted build.
3
+ *
4
+ * A plain page with no framework and no build step: it reads the route table the
5
+ * build wrote into `operations.json`, renders a form per route from the
6
+ * parameters the API declares, and sends the request the way an application
7
+ * would - same origin, same service worker, same answers. Nothing here is a
8
+ * second implementation of the API; a route that fails here fails for the app
9
+ * too, which is the point of having it on a demo site.
10
+ *
11
+ * Three deliberate choices:
12
+ *
13
+ * - no innerHTML with anything that came from the API. A response body is data
14
+ * (it can be a CSV of a visitor's own making), so it goes into textContent.
15
+ * - the host is started here. A visitor can land on this page directly, and the
16
+ * service worker is only registered by the application's boot loader - so this
17
+ * page registers it too, in the same shape, before it sends anything.
18
+ * - `window.SASjsMock.as()` is how a visitor becomes another account. It returns
19
+ * a real token pair, and the console sends it as a bearer token, so the
20
+ * refusal a restricted account gets is the refusal an application gets.
21
+ */
22
+ ;(function () {
23
+ 'use strict'
24
+
25
+ /**
26
+ * The site's root.
27
+ *
28
+ * Taken from the worker that controls this page, `<base>/sw.js`: that is the
29
+ * one statement of the base the page carries, and it holds wherever the console
30
+ * sits - a copy under `<base>/tools/api-console/` still calls the site's own
31
+ * API rather than a folder that happens to be two levels up. Only a worker
32
+ * whose root covers this page counts, since a page can be controlled by another
33
+ * site's worker, and one registered below the page names a folder inside it. A
34
+ * page with no such worker falls back to its own URL, one level below the root.
35
+ */
36
+ const base = (function () {
37
+ // this page's own folder, however it was typed: with the page, with the
38
+ // file, or without the trailing slash
39
+ let dir = location.pathname
40
+ if (/\/index\.html?$/.test(dir)) dir = dir.replace(/index\.html?$/, '')
41
+ if (!dir.endsWith('/')) dir += '/'
42
+ const parent = dir.replace(/[^/]+\/$/, '')
43
+
44
+ const script =
45
+ navigator.serviceWorker && navigator.serviceWorker.controller
46
+ ? navigator.serviceWorker.controller.scriptURL
47
+ : ''
48
+ if (script) {
49
+ const swPath = new URL(script).pathname
50
+ const root = /\/sw\.js$/.test(swPath) ? swPath.replace(/sw\.js$/, '') : ''
51
+ if (root && parent.startsWith(root)) return root
52
+ }
53
+ return parent || '/'
54
+ })()
55
+
56
+ const apiUrl = (path) => base + String(path).replace(/^\/+/, '')
57
+ const el = (id) => document.getElementById(id)
58
+
59
+ const TOKEN_KEY = 'mock-console-token'
60
+
61
+ /** The types the form treats as text rather than as JSON shapes. */
62
+ const SCALAR_TYPES = ['string', 'number', 'integer', 'boolean']
63
+
64
+ /** The parameter groups the form renders, in the order it renders them. */
65
+ const FORM_GROUPS = ['path', 'query', 'body']
66
+
67
+ const state = {
68
+ operations: [],
69
+ selected: null,
70
+ accounts: [],
71
+ admin: null,
72
+ /**
73
+ * The bearer token of the account the visitor chose to act as, if any.
74
+ *
75
+ * It has to survive the reload `as()` performs to apply the switch, so it
76
+ * lives in sessionStorage for the length of that navigation - the only place
77
+ * a page can keep something across its own restart. The worker applies the
78
+ * subject either way; the token is what makes the calls below carry the
79
+ * credential the API itself checks.
80
+ */
81
+ token: null,
82
+ actingAs: null
83
+ }
84
+
85
+ // -------------------------------------------------------------- the host
86
+ const loadScript = (src) =>
87
+ new Promise((resolve) => {
88
+ const script = document.createElement('script')
89
+ script.src = src
90
+ script.onload = resolve
91
+ script.onerror = () => resolve(false)
92
+ document.body.appendChild(script)
93
+ return true
94
+ })
95
+
96
+ /**
97
+ * Starts the demo backend for this page.
98
+ *
99
+ * The application's boot loader registers the worker; a visitor who lands on
100
+ * `/api-console/` first has no loader, and every request would reach the static host
101
+ * and 404. So: register it beside the site, wait for it to control this page,
102
+ * then load `host/persist.js`, which is what hands the worker the drive this
103
+ * browser already has and keeps whatever this page changes.
104
+ *
105
+ * The reload guard is the loader's: a worker that has not claimed the page
106
+ * after a few seconds gets one reload, because a registration that completes
107
+ * after the navigation commits only controls the next one.
108
+ */
109
+ const startHost = async () => {
110
+ // the property, not just its presence: an environment can expose
111
+ // `navigator.serviceWorker` as undefined, and `'serviceWorker' in navigator`
112
+ // is true for that
113
+ if (!navigator.serviceWorker) {
114
+ console.warn('[api console] service workers are unavailable')
115
+ return
116
+ }
117
+
118
+ // the loader on the app's page registers `<base>/sw.js?v=<version>`, and a
119
+ // second registration for the same scope under a different URL replaces it -
120
+ // so this asks for the same one, stamped the same way
121
+ const stamp = document.querySelector('meta[name="sasjs-mock-version"]')
122
+ const version = stamp && stamp.getAttribute('content')
123
+ const workerUrl =
124
+ new URL('sw.js', new URL(base, location.origin)).href +
125
+ (version && version !== '__MOCK_VERSION__' ? '?v=' + version : '')
126
+
127
+ try {
128
+ await navigator.serviceWorker.register(workerUrl, {
129
+ scope: base,
130
+ updateViaCache: 'none'
131
+ })
132
+ } catch (e) {
133
+ console.error('[api console] the demo host could not register: ' + e)
134
+ return
135
+ }
136
+
137
+ if (!navigator.serviceWorker.controller) {
138
+ await new Promise((resolve) => {
139
+ const timer = setTimeout(resolve, 4000)
140
+ navigator.serviceWorker.addEventListener(
141
+ 'controllerchange',
142
+ () => {
143
+ clearTimeout(timer)
144
+ resolve()
145
+ },
146
+ { once: true }
147
+ )
148
+ })
149
+ }
150
+
151
+ if (!navigator.serviceWorker.controller) {
152
+ if (!sessionStorage.getItem('mock-console-reloaded')) {
153
+ sessionStorage.setItem('mock-console-reloaded', '1')
154
+ location.reload()
155
+ return
156
+ }
157
+ console.error(
158
+ '[api console] the demo host did not take control; calls may not answer'
159
+ )
160
+ }
161
+
162
+ await loadScript(apiUrl('host/persist.js'))
163
+
164
+ if (window.__sasjsMockReady) {
165
+ try {
166
+ await window.__sasjsMockReady
167
+ } catch (e) {
168
+ console.error('[api console] the drive could not be handed over: ' + e)
169
+ }
170
+ }
171
+ }
172
+
173
+ // ------------------------------------------------------------------ routes
174
+ const loadOperations = async () => {
175
+ const response = await fetch('operations.json', { cache: 'no-store' })
176
+ if (!response.ok) throw new Error('operations.json is ' + response.status)
177
+ state.operations = await response.json()
178
+ }
179
+
180
+ const groupOf = (operation) => operation.tag || 'Other'
181
+
182
+ const renderList = () => {
183
+ const filter = el('filter').value.trim().toLowerCase()
184
+ const list = el('routes')
185
+ list.textContent = ''
186
+ const matches = state.operations.filter((operation) => {
187
+ if (!filter) return true
188
+ return (
189
+ operation.path.toLowerCase().includes(filter) ||
190
+ operation.method.toLowerCase().includes(filter) ||
191
+ String(operation.summary || '')
192
+ .toLowerCase()
193
+ .includes(filter) ||
194
+ groupOf(operation).toLowerCase().includes(filter)
195
+ )
196
+ })
197
+
198
+ if (!matches.length) {
199
+ const empty = document.createElement('p')
200
+ empty.className = 'hint'
201
+ empty.textContent = 'No route matches that.'
202
+ list.appendChild(empty)
203
+ return
204
+ }
205
+
206
+ const byTag = new Map()
207
+ for (const operation of matches) {
208
+ const tag = groupOf(operation)
209
+ if (!byTag.has(tag)) byTag.set(tag, [])
210
+ byTag.get(tag).push(operation)
211
+ }
212
+
213
+ for (const tag of [...byTag.keys()].sort()) {
214
+ const heading = document.createElement('h3')
215
+ heading.textContent = tag
216
+ list.appendChild(heading)
217
+ for (const operation of byTag.get(tag)) {
218
+ const button = document.createElement('button')
219
+ button.type = 'button'
220
+ button.className = 'route'
221
+ button.dataset.id = operation.id
222
+ if (state.selected && state.selected.id === operation.id)
223
+ button.classList.add('active')
224
+ const method = document.createElement('span')
225
+ method.className = 'method ' + String(operation.method).toLowerCase()
226
+ method.textContent = operation.method
227
+ const path = document.createElement('span')
228
+ path.className = 'path'
229
+ path.textContent = String(operation.path).replace(/^\/SASjsApi/, '')
230
+ button.appendChild(method)
231
+ button.appendChild(path)
232
+ button.title = operation.summary || operation.id
233
+ button.addEventListener('click', () => select(operation))
234
+ list.appendChild(button)
235
+ }
236
+ }
237
+ }
238
+
239
+ // ------------------------------------------------------------------- forms
240
+ const fieldFor = (parameter, value) => {
241
+ const wrap = document.createElement('label')
242
+ wrap.className = 'field'
243
+ const name = document.createElement('span')
244
+ name.className = 'name'
245
+ name.textContent = parameter.name
246
+ const where = document.createElement('span')
247
+ where.className = 'where'
248
+ where.textContent = parameter.in + (parameter.required ? ', required' : '')
249
+ wrap.appendChild(name)
250
+ wrap.appendChild(where)
251
+
252
+ let input
253
+ if (parameter.type === 'boolean') {
254
+ // three states rather than a checkbox, because the API reads "absent" and
255
+ // "false" differently: every boolean in the route table is optional, and an
256
+ // untouched `isAdmin` on PATCH /SASjsApi/user/{uid} would send `false` and
257
+ // demote the account it was meant to leave alone
258
+ input = document.createElement('select')
259
+ for (const [label, optionValue] of [
260
+ ['leave unset', ''],
261
+ ['true', 'true'],
262
+ ['false', 'false']
263
+ ]) {
264
+ const option = document.createElement('option')
265
+ option.value = optionValue
266
+ option.textContent = label
267
+ input.appendChild(option)
268
+ }
269
+ if (value === true) input.value = 'true'
270
+ if (value === false) input.value = 'false'
271
+ } else {
272
+ input = document.createElement('input')
273
+ input.type = 'text'
274
+ input.value = value === undefined ? '' : String(value)
275
+ if (parameter.type && parameter.type !== 'string')
276
+ input.placeholder = parameter.type
277
+ }
278
+ input.dataset.name = parameter.name
279
+ input.dataset.in = parameter.in
280
+ input.dataset.type = parameter.type || 'string'
281
+ // no `required` hint: nothing submits this form, so constraint
282
+ // validation never runs - `send()` checks the required fields instead
283
+ wrap.appendChild(input)
284
+ return wrap
285
+ }
286
+
287
+ /**
288
+ * A value to start a field with.
289
+ *
290
+ * The login route is prefilled from the fixture the build shipped: the demo
291
+ * accounts are published so a visitor can recover from a logout, and typing
292
+ * them from a page that already lists them is busywork.
293
+ */
294
+ const initialValue = (operation, parameter) => {
295
+ if (operation.id !== 'POST /SASLogon/login' || !state.admin)
296
+ return undefined
297
+ if (parameter.name === 'username') return state.admin.username
298
+ if (parameter.name === 'password') return state.admin.password
299
+ return undefined
300
+ }
301
+
302
+ const select = (operation) => {
303
+ state.selected = operation
304
+ // the route goes in the URL: acting as another account reloads the page (the
305
+ // host restarts so every screen redraws), and a visitor should come back to
306
+ // the route they were trying rather than to an empty console
307
+ try {
308
+ history.replaceState(
309
+ null,
310
+ '',
311
+ '?route=' + encodeURIComponent(operation.id)
312
+ )
313
+ } catch (e) {
314
+ /* a page that cannot rewrite its URL still works */
315
+ }
316
+ renderList()
317
+ el('empty').hidden = true
318
+ el('route').hidden = false
319
+ el('title').textContent = operation.method + ' ' + operation.path
320
+ el('summary').textContent =
321
+ (operation.summary || '') +
322
+ (operation.implemented === false
323
+ ? ' (this host does not implement this route)'
324
+ : '')
325
+
326
+ const form = el('form')
327
+ form.textContent = ''
328
+ const params = operation.params || []
329
+ let rendered = 0
330
+ for (const where of FORM_GROUPS) {
331
+ const inGroup = params.filter((parameter) => parameter.in === where)
332
+ if (!inGroup.length) continue
333
+ const fieldset = document.createElement('fieldset')
334
+ const legend = document.createElement('legend')
335
+ legend.textContent = where === 'body' ? 'JSON body' : where
336
+ fieldset.appendChild(legend)
337
+ for (const parameter of inGroup) {
338
+ fieldset.appendChild(
339
+ fieldFor(parameter, initialValue(operation, parameter))
340
+ )
341
+ rendered++
342
+ }
343
+ form.appendChild(fieldset)
344
+ }
345
+
346
+ if (!rendered) {
347
+ const none = document.createElement('p')
348
+ none.className = 'hint'
349
+ none.textContent = 'This route takes no parameters.'
350
+ form.appendChild(none)
351
+ }
352
+
353
+ if ((operation.requestBodies || []).includes('multipart/form-data')) {
354
+ const note = document.createElement('p')
355
+ note.className = 'hint warn'
356
+ note.textContent =
357
+ 'This route takes a file upload, which this console does not send. Use the application, or a client of your own.'
358
+ form.appendChild(note)
359
+ }
360
+
361
+ el('response').hidden = true
362
+ el('timing').textContent = ''
363
+ }
364
+
365
+ // ------------------------------------------------------------------ sending
366
+ const valuesOf = () => {
367
+ const values = { path: {}, query: {}, body: {} }
368
+ for (const input of el('form').querySelectorAll('[data-name]')) {
369
+ const name = input.dataset.name
370
+ const where = input.dataset.in
371
+ const type = input.dataset.type
372
+ let value
373
+ if (type === 'boolean') {
374
+ // untouched means absent: an optional boolean the visitor never chose is
375
+ // left out of the body rather than sent as `false`
376
+ if (String(input.value) === '') continue
377
+ value = input.value === 'true'
378
+ } else {
379
+ if (String(input.value).trim() === '') continue
380
+ value = input.value
381
+ if (type === 'number' || type === 'integer') {
382
+ value = Number(value)
383
+ // JSON.stringify writes NaN as null, so a typo would reach the host as
384
+ // a missing field and its refusal would read as a host fault
385
+ if (!Number.isFinite(value)) {
386
+ const err = new Error(name + ' must be a number')
387
+ err.invalid = true
388
+ throw err
389
+ }
390
+ } else if (!SCALAR_TYPES.includes(type)) {
391
+ // A named, non-scalar type is a shape: `FileTree` on POST
392
+ // /SASjsApi/drive/deploy is refused by the package when it arrives as a
393
+ // string ("Provided not supported data format"). An enum (`RunTimeType`,
394
+ // `PermissionSettingForRoute`) is a plain word and never parses to an
395
+ // object, so it stays the string that was typed. `object` and `array`
396
+ // insist on valid JSON.
397
+ let parsed
398
+ let shape = false
399
+ try {
400
+ parsed = JSON.parse(value)
401
+ shape = parsed !== null && typeof parsed === 'object'
402
+ } catch (e) {
403
+ if (type === 'object' || type === 'array') {
404
+ const err = new Error(name + ' must be JSON: ' + e.message)
405
+ err.invalid = true
406
+ throw err
407
+ }
408
+ }
409
+ if (shape) value = parsed
410
+ else if (type === 'object' || type === 'array') {
411
+ const err = new Error(name + ' must be JSON')
412
+ err.invalid = true
413
+ throw err
414
+ }
415
+ }
416
+ }
417
+ values[where][name] = value
418
+ }
419
+ return values
420
+ }
421
+
422
+ const buildUrl = (operation, values) => {
423
+ let path = operation.path
424
+ for (const [name, value] of Object.entries(values.path))
425
+ path = path.replace('{' + name + '}', encodeURIComponent(value))
426
+ const url = apiUrl(path)
427
+ const search = new URLSearchParams()
428
+ for (const [name, value] of Object.entries(values.query))
429
+ // an object or array on the query goes as JSON: `append` would write
430
+ // `[object Object]` (or a comma-joined array) and the host's 400 would read
431
+ // as its fault
432
+ search.append(
433
+ name,
434
+ value !== null && typeof value === 'object'
435
+ ? JSON.stringify(value)
436
+ : value
437
+ )
438
+ const text = search.toString()
439
+ return text ? url + '?' + text : url
440
+ }
441
+
442
+ const send = async () => {
443
+ const operation = state.selected
444
+ if (!operation) return
445
+ el('send').disabled = true
446
+ el('timing').textContent = 'sending...'
447
+ const started = performance.now()
448
+ try {
449
+ const values = valuesOf()
450
+ // a blank required parameter would otherwise be sent as the placeholder
451
+ // itself - a literal `{sessionId}` in the path - and the 404 that came back
452
+ // would look like the host's fault
453
+ const missing = (operation.params || [])
454
+ // a form-only field (`formData`, an upload) has no input on this page, so
455
+ // requiring it would ask for something the visitor cannot give
456
+ .filter((parameter) => FORM_GROUPS.includes(parameter.in))
457
+ .filter((parameter) => parameter.required)
458
+ .filter(
459
+ (parameter) =>
460
+ !values[parameter.in] ||
461
+ values[parameter.in][parameter.name] === undefined
462
+ )
463
+ .map((parameter) => parameter.name)
464
+ if (missing.length) {
465
+ el('status').textContent = 'missing input'
466
+ el('status').className = 'status bad'
467
+ el('timing').textContent = ''
468
+ el('body').textContent =
469
+ 'This route needs ' +
470
+ missing.join(', ') +
471
+ '. Fill the field' +
472
+ (missing.length > 1 ? 's' : '') +
473
+ ' in and send again.'
474
+ el('response').hidden = false
475
+ return
476
+ }
477
+ const url = buildUrl(operation, values)
478
+ const init = { method: operation.method, headers: {} }
479
+ if (state.token) init.headers.authorization = 'Bearer ' + state.token
480
+ // a GET or HEAD with a body is refused by fetch itself, and the TypeError
481
+ // would read as the host's answer
482
+ const carriesBody =
483
+ operation.method !== 'GET' &&
484
+ operation.method !== 'HEAD' &&
485
+ Object.keys(values.body).length > 0
486
+ if (carriesBody) {
487
+ init.headers['content-type'] = 'application/json'
488
+ init.body = JSON.stringify(values.body)
489
+ }
490
+ const response = await fetch(url, init)
491
+ const elapsed = Math.round(performance.now() - started)
492
+ const text = await response.text()
493
+ el('status').textContent = response.status + ' ' + response.statusText
494
+ el('status').className = 'status ' + (response.ok ? 'ok' : 'bad')
495
+ el('timing').textContent =
496
+ elapsed +
497
+ 'ms ' +
498
+ 'as ' +
499
+ (state.actingAs || (state.admin ? state.admin.username : 'this host'))
500
+ el('body').textContent = prettyOf(
501
+ text,
502
+ response.headers.get('content-type')
503
+ )
504
+ el('response').hidden = false
505
+ } catch (e) {
506
+ // a value this page refused is the visitor's input, not the host's answer
507
+ el('status').textContent =
508
+ e && e.invalid ? 'invalid input' : 'request failed'
509
+ el('status').className = 'status bad'
510
+ el('body').textContent = (e && e.message) || String(e)
511
+ el('response').hidden = false
512
+ el('timing').textContent = ''
513
+ } finally {
514
+ el('send').disabled = false
515
+ }
516
+ }
517
+
518
+ const prettyOf = (text, contentType) => {
519
+ if (!text) return '(empty body)'
520
+ if ((contentType || '').includes('json')) {
521
+ try {
522
+ return JSON.stringify(JSON.parse(text), null, 2)
523
+ } catch (e) {
524
+ /* not JSON after all: show it as it came */
525
+ }
526
+ }
527
+ return text.length > 20000
528
+ ? text.slice(0, 20000) +
529
+ '\n... (' +
530
+ (text.length - 20000) +
531
+ ' more bytes)'
532
+ : text
533
+ }
534
+
535
+ // ---------------------------------------------------------------- accounts
536
+ /**
537
+ * The accounts the build shipped, shown so a visitor can sign in to the
538
+ * application and knows how to get back in after logging out.
539
+ *
540
+ * They are not a secret: `host/identity.json` is served by the site, and a
541
+ * browser-hosted build has no login wall to protect. A project that ships its
542
+ * own fixture sees its own accounts here.
543
+ */
544
+ const loadAccounts = async () => {
545
+ try {
546
+ const response = await fetch(apiUrl('host/identity.json'), {
547
+ cache: 'no-store'
548
+ })
549
+ if (!response.ok) return
550
+ const fixture = await response.json()
551
+ state.accounts = (fixture && fixture.users) || []
552
+ // the account whose credentials are published and offered back: an admin if
553
+ // the fixture ships one, otherwise the first account - and the page says
554
+ // which, because a fixture without an admin is a project's choice and its
555
+ // first user is not one
556
+ const administrator = state.accounts.find((user) => user.isAdmin)
557
+ state.admin = administrator || state.accounts[0] || null
558
+ state.adminIsAdministrator = Boolean(administrator)
559
+ await adoptStoredSubject()
560
+ setWho()
561
+ renderAccounts()
562
+ } catch (e) {
563
+ /* a site with no fixture simply shows nothing here */
564
+ }
565
+ }
566
+
567
+ /**
568
+ * The account this page is already answered as.
569
+ *
570
+ * `as()` reloads the page and stores the subject, and the worker answers as it
571
+ * from then on - so the console has to read that back, or it would label a
572
+ * restricted session as the administrator.
573
+ */
574
+ const adoptStoredSubject = async () => {
575
+ if (!window.SASjsMock || !window.SASjsMock.identity) return
576
+ try {
577
+ const stored = await window.SASjsMock.identity()
578
+ const subject = stored && stored.subject
579
+ if (subject && (!state.admin || subject !== state.admin.username))
580
+ state.actingAs = subject
581
+ } catch (e) {
582
+ /* no stored identity: the host answers as its fixture administrator */
583
+ }
584
+ }
585
+
586
+ const setWho = () => {
587
+ const who = el('who')
588
+ if (state.actingAs) {
589
+ who.textContent = 'acting as ' + state.actingAs
590
+ who.classList.add('acting')
591
+ return
592
+ }
593
+ who.classList.remove('acting')
594
+ who.textContent = state.admin
595
+ ? 'answering as ' + state.admin.username
596
+ : 'connected'
597
+ }
598
+
599
+ const actAs = async (username) => {
600
+ if (!window.SASjsMock || !window.SASjsMock.as) return
601
+ try {
602
+ const pair = await window.SASjsMock.as(username)
603
+ // `as()` schedules the reload on a 0ms timer and resolves first, so this
604
+ // runs before the page goes away: storing the pair here is what makes the
605
+ // calls on the far side carry a credential
606
+ const target = username || (state.admin && state.admin.username) || null
607
+ // returning to the administrator is not "acting as" anyone: the host
608
+ // answers as them already, and a header would just be a credential for the
609
+ // account the fixture starts with
610
+ const backToAdmin = Boolean(
611
+ state.admin && target === state.admin.username
612
+ )
613
+ const token =
614
+ !backToAdmin && pair && pair.accessToken ? pair.accessToken : null
615
+ try {
616
+ if (token) sessionStorage.setItem(TOKEN_KEY, token)
617
+ else sessionStorage.removeItem(TOKEN_KEY)
618
+ } catch (e) {
619
+ /* a page that refuses storage still has the worker's subject */
620
+ }
621
+ state.token = token
622
+ state.actingAs = backToAdmin ? null : target
623
+ } catch (e) {
624
+ state.token = null
625
+ state.actingAs = null
626
+ alert('Could not act as that account: ' + e.message)
627
+ }
628
+ setWho()
629
+ renderAccounts()
630
+ }
631
+
632
+ const renderAccounts = () => {
633
+ const host = el('accounts')
634
+ if (!host || !state.accounts.length) return
635
+ host.textContent = ''
636
+
637
+ const heading = document.createElement('h3')
638
+ heading.textContent = 'Demo accounts'
639
+ host.appendChild(heading)
640
+ const note = document.createElement('p')
641
+ note.className = 'hint'
642
+ note.textContent =
643
+ 'This is a browser demo, so these are published on purpose. Use them to ' +
644
+ 'sign in to the application. "Act as" sends every call below with that ' +
645
+ "account's token, which is how the refusals a restricted user gets are shown."
646
+ if (!state.adminIsAdministrator && state.admin)
647
+ // the fixture decides who exists; saying which account is standing in keeps
648
+ // the page from calling an ordinary user an administrator
649
+ note.textContent +=
650
+ ' This fixture ships no administrator, so the page names ' +
651
+ state.admin.username +
652
+ ' wherever it would say "the demo administrator".'
653
+ host.appendChild(note)
654
+
655
+ const table = document.createElement('table')
656
+ for (const user of state.accounts) {
657
+ const row = document.createElement('tr')
658
+ const name = document.createElement('td')
659
+ name.textContent = user.username
660
+ const password = document.createElement('td')
661
+ password.textContent = user.password || '(none)'
662
+ const role = document.createElement('td')
663
+ role.textContent = user.isAdmin ? 'admin' : 'user'
664
+ const action = document.createElement('td')
665
+ const button = document.createElement('button')
666
+ button.type = 'button'
667
+ button.className = 'link'
668
+ button.dataset.user = user.username
669
+ const isCurrent = state.actingAs === user.username
670
+ button.textContent = isCurrent ? 'acting as this' : 'Act as'
671
+ button.disabled = isCurrent
672
+ button.addEventListener('click', () => actAs(user.username))
673
+ action.appendChild(button)
674
+ row.appendChild(name)
675
+ row.appendChild(password)
676
+ row.appendChild(role)
677
+ row.appendChild(action)
678
+ table.appendChild(row)
679
+ }
680
+ host.appendChild(table)
681
+
682
+ if (state.actingAs) {
683
+ const back = document.createElement('button')
684
+ back.type = 'button'
685
+ back.className = 'link'
686
+ back.textContent = state.adminIsAdministrator
687
+ ? 'Back to the demo administrator'
688
+ : 'Back to ' + (state.admin ? state.admin.username : 'this host')
689
+ back.addEventListener('click', () => actAs())
690
+ host.appendChild(back)
691
+ }
692
+ }
693
+
694
+ // ------------------------------------------------------------------ restart
695
+ /**
696
+ * Deletes the demo's database, and waits for the delete to finish.
697
+ *
698
+ * A delete blocked by an open connection - the host keeps one while a request
699
+ * runs - completes after whatever the page does next, so a page that navigates
700
+ * immediately comes back to the drive it asked to clear.
701
+ */
702
+ const deleteDriveDatabase = () =>
703
+ new Promise((resolve, reject) => {
704
+ const request = indexedDB.deleteDatabase('sasjs-mock-host')
705
+ request.onsuccess = () => resolve()
706
+ request.onerror = () =>
707
+ reject(request.error || new Error('the drive could not be deleted'))
708
+ request.onblocked = () =>
709
+ reject(
710
+ new Error(
711
+ 'the demo host still holds the drive; close its other tabs and try again'
712
+ )
713
+ )
714
+ })
715
+
716
+ /** Where a reset returns to: this page, without the flag but with the route. */
717
+ const resetTarget = () => {
718
+ const query = new URLSearchParams(location.search)
719
+ query.delete('reset')
720
+ const rest = query.toString()
721
+ return location.pathname + (rest ? '?' + rest : '')
722
+ }
723
+
724
+ /**
725
+ * `?reset=1` starts the demo again from the state the build ships.
726
+ *
727
+ * The drive lives in IndexedDB, and the worker is what reseeds it, so the
728
+ * honest reset is the host's own: `SASjsMock.reset()` clears the stored drive
729
+ * and seeds it from the snapshot, then the page reloads onto a clean console. A
730
+ * browser's "hard refresh" does NOT do this - it bypasses the HTTP cache only -
731
+ * which is why there is a link for it.
732
+ */
733
+ const resetIfAsked = async () => {
734
+ // `=1` exactly: the page tells the visitor `?reset=1`, and `?reset=0` is the
735
+ // one thing someone will type to say "not now"
736
+ if (new URLSearchParams(location.search).get('reset') !== '1') return false
737
+ try {
738
+ if (window.SASjsMock && window.SASjsMock.reset)
739
+ await window.SASjsMock.reset()
740
+ else await deleteDriveDatabase()
741
+ sessionStorage.clear()
742
+ localStorage.removeItem('sasjs-mock-build')
743
+ } catch (e) {
744
+ // the drive is unchanged, so replacing the page would come straight back to
745
+ // it and look like the flag did nothing
746
+ console.error('[api console] reset failed: ' + e)
747
+ el('routes').textContent =
748
+ 'The demo could not be reset: ' + ((e && e.message) || e)
749
+ return true
750
+ }
751
+ // back to this page, with the flag gone: the console's folder is the page's
752
+ // own, so a build that moves the folder cannot strand the redirect
753
+ location.replace(resetTarget())
754
+ return true
755
+ }
756
+
757
+ // ------------------------------------------------------------------- start
758
+ const start = async () => {
759
+ try {
760
+ state.token = sessionStorage.getItem(TOKEN_KEY) || null
761
+ } catch (e) {
762
+ state.token = null
763
+ }
764
+ el('filter').addEventListener('input', renderList)
765
+ el('send').addEventListener('click', send)
766
+ el('copy').addEventListener('click', async () => {
767
+ try {
768
+ await navigator.clipboard.writeText(el('body').textContent)
769
+ el('copy').textContent = 'Copied'
770
+ setTimeout(() => (el('copy').textContent = 'Copy'), 1200)
771
+ } catch (e) {
772
+ /* clipboard refused: the text is on screen and selectable */
773
+ }
774
+ })
775
+
776
+ await startHost()
777
+ if (await resetIfAsked()) return
778
+
779
+ await Promise.all([loadOperations(), loadAccounts()])
780
+ renderList()
781
+
782
+ const wanted = new URLSearchParams(location.search).get('route')
783
+ const chosen = wanted
784
+ ? state.operations.find((operation) => operation.id === wanted)
785
+ : undefined
786
+ if (chosen) select(chosen)
787
+ }
788
+
789
+ // the redirect itself cannot be observed in a page that does not navigate; the
790
+ // choice of target can, and it is what a folder move would break
791
+ window.SASjsApiConsole = { resetTarget }
792
+
793
+ start().catch((e) => {
794
+ const list = el('routes')
795
+ if (list) list.textContent = 'The console could not start: ' + e.message
796
+ })
797
+ })()