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