@signalk/app-dock 1.1.0-beta.2 → 1.2.0-beta.1

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.
package/README.md CHANGED
@@ -13,6 +13,8 @@ A macOS-style app dock for switching between Signal K webapps on touch screens.
13
13
  - **Autostart** -- optionally load a default app immediately on open
14
14
  - **keep-alive or destroy** iframe lifecycle
15
15
  - **Night/day mode toggle** -- optional sun/moon button that flips `environment.mode` via a PUT handler
16
+ - **Fullscreen toggle** -- optional button that hides browser chrome via the Fullscreen API; feature-detected, so hidden on devices without API support (notably iPadOS Safari -- use Add to Home Screen there)
17
+ - **Installable as an app** -- ships a web app manifest and apple-touch-icon so iOS, Android, and desktop browsers can install the dock to the home screen and launch it in standalone mode (no browser chrome). Works over plain HTTP -- no SSL required
16
18
  - **Active dot indicator**, label tooltip, haptic feedback
17
19
  - **Embedded config panel** in the admin UI with webapp discovery, drag-to-reorder, and live preview
18
20
 
@@ -59,15 +61,30 @@ Open **Plugin Config > App Dock** in the admin UI. The embedded configurator pro
59
61
 
60
62
  ### Settings
61
63
 
62
- | Setting | Default | Description |
63
- | --------------------- | ------------ | ------------------------------------------------------------------------------------------------ |
64
- | `position` | `bottom` | Dock edge: `bottom`, `top`, `left`, `right` |
65
- | `iframeMode` | `keep-alive` | `keep-alive` loads each app once and hides/shows (faster, more RAM); `destroy` reloads each time |
66
- | `iconSize` | `56` | Base icon size in px |
67
- | `magnification` | `true` | Enable macOS-style magnification effect |
68
- | `magnificationScale` | `1.7` | Max icon scale (1.0-2.5) |
69
- | `showNightModeButton` | `true` | Show sun/moon button in dock; PUT-writes `environment.mode` |
70
- | `showExitButton` | `false` | Show X button that returns to the Signal K admin UI |
64
+ | Setting | Default | Description |
65
+ | ---------------------- | ------------ | ------------------------------------------------------------------------------------------------ |
66
+ | `position` | `bottom` | Dock edge: `bottom`, `top`, `left`, `right` |
67
+ | `iframeMode` | `keep-alive` | `keep-alive` loads each app once and hides/shows (faster, more RAM); `destroy` reloads each time |
68
+ | `iconSize` | `56` | Base icon size in px |
69
+ | `magnification` | `true` | Enable macOS-style magnification effect |
70
+ | `magnificationScale` | `1.7` | Max icon scale (1.0-2.5) |
71
+ | `showNightModeButton` | `true` | Show sun/moon button in dock; PUT-writes `environment.mode` |
72
+ | `showFullscreenButton` | `false` | Show fullscreen toggle in dock; hidden automatically where the Fullscreen API is unavailable |
73
+ | `showExitButton` | `false` | Show X button that returns to the Signal K admin UI |
74
+
75
+ ## Access
76
+
77
+ With security enabled, what the dock offers depends on the visitor:
78
+
79
+ | Visitor | Apps shown | Night-mode toggle | "Don't show again" in the tour |
80
+ | ---------------------------------------------- | ---------- | ----------------- | ------------------------------ |
81
+ | admin | configured | yes | yes |
82
+ | readwrite user | configured | yes | yes |
83
+ | readonly user | configured | no | no |
84
+ | not signed in, server allows read-only access | configured | no | no |
85
+ | not signed in, server refuses read-only access | built-in | no | no |
86
+
87
+ Visitors who are not admins reach the plugin's routes through `router.access()`, which signalk-server 2.31 added. On older servers they get the built-in list, the same apps the plugin seeds on its first start. Only admins can open **Plugin Config**, and "Don't show again" hides the tour for every visitor. Everyone who gets the configured apps can also read their URLs from the plugin's `/settings`, so keep credentials out of app URLs.
71
88
 
72
89
  ## Development
73
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalk/app-dock",
3
- "version": "1.1.0-beta.2",
3
+ "version": "1.2.0-beta.1",
4
4
  "description": "macOS-style configurable app dock for switching between SignalK webapps on touch screens",
5
5
  "repository": {
6
6
  "url": "https://github.com/SignalK/app-dock"
package/plugin/index.js CHANGED
@@ -12,9 +12,9 @@ const DEFAULT_APPS = [
12
12
  {
13
13
  enabled: true,
14
14
  autostart: false,
15
- url: '/@mxtommy/kip/',
16
- label: 'KIP',
17
- icon: '/@mxtommy/kip/assets/icon-72x72.png',
15
+ url: '/@halos-org/skip/',
16
+ label: 'Skip',
17
+ icon: '/@halos-org/skip/assets/icon-72x72.png',
18
18
  color: ''
19
19
  },
20
20
  {
@@ -30,6 +30,26 @@ const DEFAULT_APPS = [
30
30
  module.exports = (app) => {
31
31
  let pluginSettings = {}
32
32
  let resolvedApps = []
33
+ let running = false
34
+
35
+ function resolveApps(settings) {
36
+ return (Array.isArray(settings.apps) ? settings.apps : [])
37
+ .filter((a) => a.enabled !== false)
38
+ .map((a) => ({
39
+ label: a.label || a.url,
40
+ url: a.url,
41
+ icon: a.icon || null,
42
+ color: a.color || null,
43
+ autostart: a.autostart || false
44
+ }))
45
+ }
46
+
47
+ // The configuration as saved, the one /config serves. While the plugin is
48
+ // not running, pluginSettings is empty (never started) or holds what the
49
+ // last start() got, which a later save may have replaced.
50
+ function savedSettings() {
51
+ return app.readPluginOptions().configuration || {}
52
+ }
33
53
 
34
54
  function getWebapps() {
35
55
  return (app.webapps || [])
@@ -83,26 +103,27 @@ module.exports = (app) => {
83
103
  )
84
104
  }
85
105
 
86
- resolvedApps = (settings.apps || [])
87
- .filter((a) => a.enabled !== false)
88
- .map((a) => ({
89
- label: a.label || a.url,
90
- url: a.url,
91
- icon: a.icon || null,
92
- color: a.color || null,
93
- autostart: a.autostart || false
94
- }))
106
+ resolvedApps = resolveApps(settings)
107
+ running = true
95
108
 
96
109
  app.debug('App Dock: resolved %d apps: %s', resolvedApps.length, resolvedApps.map((a) => a.label).join(', '))
97
110
  },
98
111
 
99
- stop() {},
112
+ stop() {
113
+ running = false
114
+ },
100
115
 
101
116
  registerWithRouter(router) {
102
- router.get('/settings', (req, res) => {
117
+ // Routes registered on the router itself are admin-only. signalk-server
118
+ // 2.31 and later add router.access(), which opens a route to readonly or
119
+ // readwrite users; on older servers every route stays admin-only.
120
+ const openTo = (level) => (typeof router.access === 'function' ? router.access(level) : router)
121
+
122
+ openTo('readonly').get('/settings', (req, res) => {
123
+ const settings = running ? pluginSettings : savedSettings()
103
124
  res.json({
104
- ...pluginSettings,
105
- apps: resolvedApps
125
+ ...settings,
126
+ apps: running ? resolvedApps : resolveApps(settings)
106
127
  })
107
128
  })
108
129
 
@@ -110,13 +131,15 @@ module.exports = (app) => {
110
131
  res.json(getWebapps())
111
132
  })
112
133
 
113
- router.get('/mode', (req, res) => {
134
+ openTo('readonly').get('/mode', (req, res) => {
114
135
  const current = app.getSelfPath('environment.mode')
115
136
  res.json({ value: (current && current.value) || 'day' })
116
137
  })
117
138
 
118
- router.post('/dismiss-tour', (req, res) => {
119
- const updated = { ...pluginSettings, tourDismissed: true }
139
+ openTo('readwrite').post('/dismiss-tour', (req, res) => {
140
+ // savePluginOptions replaces the whole saved configuration, so the
141
+ // update starts from a complete one.
142
+ const updated = { ...(running ? pluginSettings : savedSettings()), tourDismissed: true }
120
143
  app.savePluginOptions(updated, (err) => {
121
144
  if (err) {
122
145
  app.error('App Dock: failed to save tourDismissed: ' + err.message)
package/public/dock.js CHANGED
@@ -41,11 +41,12 @@
41
41
  apps: []
42
42
  }
43
43
 
44
- // Fallback app list used when we can't read server config — i.e. the
45
- // visitor has no admin access (/plugins/* requires admin in signalk-server,
46
- // so anonymous, readonly and readwrite users all 401/403). Mirrors the
47
- // server-side DEFAULT_APPS in plugin/index.js so the demo at
48
- // demo.signalk.org behaves sensibly for non-admin visitors.
44
+ // Fallback app list for a visitor who is not an admin when the plugin's
45
+ // /settings refuses them. signalk-server before 2.31 keeps every plugin
46
+ // route admin-only, and any version refuses a visitor who is not signed in
47
+ // unless it allows read-only access. Mirrors the server-side DEFAULT_APPS
48
+ // in plugin/index.js so the demo at demo.signalk.org behaves sensibly for
49
+ // non-admin visitors.
49
50
  const NO_ADMIN_DEFAULTS = {
50
51
  showNightModeButton: true,
51
52
  apps: [
@@ -60,9 +61,9 @@
60
61
  {
61
62
  enabled: true,
62
63
  autostart: false,
63
- url: '/@mxtommy/kip/',
64
- label: 'KIP',
65
- icon: '/@mxtommy/kip/assets/icon-72x72.png',
64
+ url: '/@halos-org/skip/',
65
+ label: 'Skip',
66
+ icon: '/@halos-org/skip/assets/icon-72x72.png',
66
67
  color: ''
67
68
  },
68
69
  {
@@ -76,24 +77,26 @@
76
77
  ]
77
78
  }
78
79
 
79
- // ─── Detect admin access ────────────────────────────────────────────────────
80
- // /skServer/loginStatus is public even when allow_readonly is on and
81
- // /plugins/* is admin-gated. Only logged-in admins can read plugin
82
- // settings — anonymous, readonly and readwrite users all 401/403. We set
83
- // noAdminAccess accordingly and take the fallback path for all of them.
80
+ // ─── Detect access level ────────────────────────────────────────────────────
81
+ // /skServer/loginStatus is public. Only an admin can read the plugin's
82
+ // /config (signalk-server keeps it admin-only), so every other visitor has
83
+ // noAdminAccess and reads the dock from /settings instead. canWrite marks
84
+ // the users signalk-server lets write: admins and readwrite users.
84
85
  // When authenticationRequired is false (no-auth / dummy-security setups),
85
86
  // /plugins/* is reachable without a login, so treat the visitor as
86
87
  // full-access instead of falling through to the hardcoded fallback list.
87
88
  // Try /skServer/loginStatus first, fall back to the legacy /loginStatus.
88
89
  let noAdminAccess = false
90
+ let canWrite = true
89
91
  for (const url of ['/skServer/loginStatus', '/loginStatus']) {
90
92
  try {
91
93
  const res = await fetch(url)
92
94
  if (res.ok) {
93
95
  const data = await res.json()
94
96
  const authOff = data.authenticationRequired === false
95
- const isAdmin = data.status === 'loggedIn' && data.userLevel === 'admin'
96
- noAdminAccess = !authOff && !isAdmin
97
+ const level = data.status === 'loggedIn' ? data.userLevel : undefined
98
+ noAdminAccess = !authOff && level !== 'admin'
99
+ canWrite = authOff || level === 'admin' || level === 'readwrite'
97
100
  break
98
101
  }
99
102
  } catch {
@@ -101,10 +104,31 @@
101
104
  }
102
105
  }
103
106
 
107
+ // The dock as the plugin serves it on /settings: the plugin configuration
108
+ // with the enabled apps resolved. The plugin opens this route to every user
109
+ // with read access on signalk-server 2.31 and later. Null when the route
110
+ // refuses this visitor or answers without an app list.
111
+ async function fetchSharedConfig() {
112
+ try {
113
+ const res = await fetch('/plugins/signalk-app-dock/settings')
114
+ if (!res.ok) return null
115
+ const data = await res.json()
116
+ return Array.isArray(data.apps) ? { ...DEFAULTS, ...data } : null
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
104
122
  // ─── Load config from plugin endpoints ───────────────────────────────────────
105
123
  let cfg = { ...DEFAULTS }
124
+ // Whether the dock keeps calling the plugin's routes after loading: always
125
+ // for an admin, and for anyone else once /settings has served them the dock
126
+ // (the plugin opens /settings and /mode to the same users).
127
+ let pluginReachable = !noAdminAccess
106
128
  if (noAdminAccess) {
107
- cfg = { ...DEFAULTS, ...NO_ADMIN_DEFAULTS }
129
+ const shared = await fetchSharedConfig()
130
+ pluginReachable = shared !== null
131
+ cfg = shared || { ...DEFAULTS, ...NO_ADMIN_DEFAULTS }
108
132
  } else {
109
133
  try {
110
134
  const [configRes, settingsRes] = await Promise.all([
@@ -147,9 +171,13 @@
147
171
 
148
172
  // ─── Night mode state ────────────────────────────────────────────────────────
149
173
  let currentMode = 'day'
174
+ // The button reads the mode from the plugin's /mode and switches it with a
175
+ // PUT that signalk-server accepts from admins and readwrite users only, so
176
+ // only visitors who can do both get the button.
177
+ const nightModeUsable = pluginReachable && canWrite
150
178
 
151
179
  async function fetchCurrentMode() {
152
- if (noAdminAccess) return
180
+ if (!pluginReachable) return
153
181
  try {
154
182
  const res = await fetch('/plugins/signalk-app-dock/mode')
155
183
  if (res.ok) {
@@ -171,7 +199,7 @@
171
199
  }
172
200
 
173
201
  async function toggleNightMode() {
174
- if (noAdminAccess) return
202
+ if (!nightModeUsable) return
175
203
  await fetchCurrentMode()
176
204
  const newMode = currentMode === 'night' ? 'day' : 'night'
177
205
  try {
@@ -338,7 +366,8 @@
338
366
  const radius = Math.round(sz * 0.25) + 'px'
339
367
  const lblCls = labelClass()
340
368
 
341
- if (cfg.showNightModeButton) {
369
+ const showNightMode = cfg.showNightModeButton && nightModeUsable
370
+ if (showNightMode) {
342
371
  $dockInner.appendChild(createUtilityItem('dock-item-nightmode', '\u2600\uFE0F', 'Night mode', toggleNightMode))
343
372
  }
344
373
 
@@ -346,7 +375,7 @@
346
375
  $dockInner.appendChild(createUtilityItem('dock-item-fullscreen', '\u26F6', 'Fullscreen', toggleFullscreen))
347
376
  }
348
377
 
349
- if (cfg.showNightModeButton || (cfg.showFullscreenButton && fullscreenSupported())) {
378
+ if (showNightMode || (cfg.showFullscreenButton && fullscreenSupported())) {
350
379
  $dockInner.appendChild(createSeparator())
351
380
  }
352
381
 
@@ -587,8 +616,10 @@
587
616
 
588
617
  hideIdleHint()
589
618
 
590
- document.querySelectorAll('.dock-item').forEach((el, i) => {
591
- el.classList.toggle('active', i === index)
619
+ // The night-mode and fullscreen buttons are .dock-items too; only app
620
+ // items carry a data-index.
621
+ document.querySelectorAll('.dock-item').forEach((el) => {
622
+ el.classList.toggle('active', el.dataset.index === String(index))
592
623
  })
593
624
 
594
625
  const alreadyLoaded = !!iframes[app.url]
@@ -767,7 +798,7 @@
767
798
  // ─── Init ────────────────────────────────────────────────────────────────────
768
799
  buildDock()
769
800
 
770
- if (cfg.showNightModeButton && !noAdminAccess) {
801
+ if (cfg.showNightModeButton && nightModeUsable) {
771
802
  fetchCurrentMode()
772
803
  setInterval(fetchCurrentMode, 5000)
773
804
  }
@@ -791,21 +822,25 @@
791
822
 
792
823
  async function refreshConfig() {
793
824
  try {
794
- const [configRes, settingsRes] = await Promise.all([
795
- fetch('/plugins/signalk-app-dock/config'),
796
- fetch('/plugins/signalk-app-dock/settings')
797
- ])
798
825
  const prev = cfg
799
826
  let next = cfg
800
- if (configRes.ok) {
801
- const data = await configRes.json()
802
- const pluginCfg = data.configuration || data
803
- next = { ...DEFAULTS, ...pluginCfg, apps: prev.apps }
804
- }
805
- if (settingsRes.ok) {
806
- const data = await settingsRes.json()
807
- if (Array.isArray(data.apps) && data.apps.length > 0) {
808
- next = { ...next, apps: data.apps }
827
+ if (noAdminAccess) {
828
+ next = (await fetchSharedConfig()) || prev
829
+ } else {
830
+ const [configRes, settingsRes] = await Promise.all([
831
+ fetch('/plugins/signalk-app-dock/config'),
832
+ fetch('/plugins/signalk-app-dock/settings')
833
+ ])
834
+ if (configRes.ok) {
835
+ const data = await configRes.json()
836
+ const pluginCfg = data.configuration || data
837
+ next = { ...DEFAULTS, ...pluginCfg, apps: prev.apps }
838
+ }
839
+ if (settingsRes.ok) {
840
+ const data = await settingsRes.json()
841
+ if (Array.isArray(data.apps) && data.apps.length > 0) {
842
+ next = { ...next, apps: data.apps }
843
+ }
809
844
  }
810
845
  }
811
846
  const structuralChanged = STRUCTURAL_KEYS.some((k) => prev[k] !== next[k])
@@ -824,7 +859,7 @@
824
859
  }
825
860
  }
826
861
 
827
- if (!noAdminAccess) setInterval(refreshConfig, 5000)
862
+ if (pluginReachable) setInterval(refreshConfig, 5000)
828
863
 
829
864
  // ─── Welcome tour ────────────────────────────────────────────────────────────
830
865
  const $tourOverlay = document.getElementById('tour-overlay')
@@ -843,11 +878,13 @@
843
878
 
844
879
  if ($tourGotIt) $tourGotIt.addEventListener('click', hideTour)
845
880
 
846
- // Anonymous visitors can't persist tour dismissal server-side (POST
847
- // /dismiss-tour requires auth), so we drop the "Don't show again" button
848
- // rather than advertise a setting we can't honour. The tour reappears on
849
- // next visit — correct semantics for a read-only visitor.
850
- if (noAdminAccess) {
881
+ // "Don't show again" POSTs /dismiss-tour, which saves tourDismissed in the
882
+ // plugin configuration, so it applies to every user. The plugin opens that
883
+ // route to readwrite users. Everyone else who is not an admin, and anyone
884
+ // /settings did not serve the dock to, gets no button rather than one that
885
+ // advertises a setting we can't honour. The tour reappears on their next
886
+ // visit.
887
+ if (!pluginReachable || !canWrite) {
851
888
  if ($tourDismiss) $tourDismiss.remove()
852
889
  document.getElementById('tour-buttons')?.style.setProperty('justify-content', 'center')
853
890
  } else if ($tourDismiss) {
package/public/index.html CHANGED
@@ -52,7 +52,7 @@
52
52
  <strong>Double-tap anywhere</strong> on the screen to open the dock, then tap an icon to launch that app.
53
53
  </p>
54
54
  <p>
55
- Freeboard-SK is loading in the background and KIP is one tap away. Customize the apps, dock position, and
55
+ Freeboard-SK is loading in the background and Skip is one tap away. Customize the apps, dock position, and
56
56
  more in
57
57
  <a id="tour-link" href="#">Plugin Config</a>.
58
58
  </p>
@@ -13,6 +13,7 @@ function createMockApp(webapps = []) {
13
13
  debug: () => {},
14
14
  error: () => {},
15
15
  savePluginOptions: (opts, cb) => cb(null),
16
+ readPluginOptions: () => ({}),
16
17
  registerPutHandler: () => {},
17
18
  handleMessage: () => {},
18
19
  getSelfPath: () => null
@@ -220,6 +221,85 @@ describe('settings endpoint', () => {
220
221
  }
221
222
  routes['/settings']({}, res)
222
223
  })
224
+
225
+ it('serves the saved configuration before the plugin has started', (t, done) => {
226
+ const app = createMockApp()
227
+ app.readPluginOptions = () => ({
228
+ enabled: false,
229
+ configuration: {
230
+ position: 'right',
231
+ apps: [
232
+ { url: '/a/', label: 'A', enabled: true },
233
+ { url: '/b/', label: 'B', enabled: false }
234
+ ]
235
+ }
236
+ })
237
+ const plugin = pluginFactory(app)
238
+
239
+ const routes = {}
240
+ plugin.registerWithRouter({
241
+ get: (path, handler) => {
242
+ routes[path] = handler
243
+ },
244
+ post: () => {}
245
+ })
246
+
247
+ const res = {
248
+ json: (data) => {
249
+ assert.equal(data.position, 'right')
250
+ assert.deepEqual(data.apps, [{ label: 'A', url: '/a/', icon: null, color: null, autostart: false }])
251
+ done()
252
+ }
253
+ }
254
+ routes['/settings']({}, res)
255
+ })
256
+
257
+ it('serves no apps when the saved apps value is not an array', (t, done) => {
258
+ const app = createMockApp()
259
+ app.readPluginOptions = () => ({ enabled: false, configuration: { apps: {} } })
260
+ const plugin = pluginFactory(app)
261
+
262
+ const routes = {}
263
+ plugin.registerWithRouter({
264
+ get: (path, handler) => {
265
+ routes[path] = handler
266
+ },
267
+ post: () => {}
268
+ })
269
+
270
+ const res = {
271
+ json: (data) => {
272
+ assert.deepEqual(data.apps, [])
273
+ done()
274
+ }
275
+ }
276
+ routes['/settings']({}, res)
277
+ })
278
+
279
+ it('serves the saved configuration after stop()', (t, done) => {
280
+ const app = createMockApp()
281
+ app.readPluginOptions = () => ({ enabled: false, configuration: { position: 'top', apps: [] } })
282
+ const plugin = pluginFactory(app)
283
+ plugin.start({ position: 'left', apps: [{ url: '/a/', label: 'A' }] })
284
+ plugin.stop()
285
+
286
+ const routes = {}
287
+ plugin.registerWithRouter({
288
+ get: (path, handler) => {
289
+ routes[path] = handler
290
+ },
291
+ post: () => {}
292
+ })
293
+
294
+ const res = {
295
+ json: (data) => {
296
+ assert.equal(data.position, 'top')
297
+ assert.deepEqual(data.apps, [])
298
+ done()
299
+ }
300
+ }
301
+ routes['/settings']({}, res)
302
+ })
223
303
  })
224
304
 
225
305
  describe('default apps seeding', () => {
@@ -237,7 +317,7 @@ describe('default apps seeding', () => {
237
317
  assert.ok(savedOptions)
238
318
  assert.equal(savedOptions.apps[0].url, '/@signalk/freeboard-sk/')
239
319
  assert.equal(savedOptions.apps[0].autostart, true)
240
- assert.equal(savedOptions.apps[1].url, '/@mxtommy/kip/')
320
+ assert.equal(savedOptions.apps[1].url, '/@halos-org/skip/')
241
321
  assert.equal(savedOptions.apps[1].autostart, false)
242
322
  assert.equal(savedOptions.apps[2].url, '/admin/')
243
323
  assert.equal(savedOptions.apps[2].label, 'Settings')
@@ -445,6 +525,35 @@ describe('dismiss-tour endpoint', () => {
445
525
  }
446
526
  routes.post['/dismiss-tour']({}, res)
447
527
  })
528
+
529
+ it('keeps the saved configuration when the plugin is not running', (t, done) => {
530
+ const app = createMockApp()
531
+ const apps = [{ url: '/a/', label: 'A', enabled: true }]
532
+ app.readPluginOptions = () => ({ enabled: false, configuration: { position: 'right', apps } })
533
+ let saved = null
534
+ app.savePluginOptions = (opts, cb) => {
535
+ saved = opts
536
+ cb(null)
537
+ }
538
+ const plugin = pluginFactory(app)
539
+
540
+ const routes = {}
541
+ plugin.registerWithRouter({
542
+ get: () => {},
543
+ post: (path, handler) => {
544
+ routes[path] = handler
545
+ }
546
+ })
547
+
548
+ const res = {
549
+ json: (data) => {
550
+ assert.equal(data.tourDismissed, true)
551
+ assert.deepEqual(saved, { position: 'right', apps, tourDismissed: true })
552
+ done()
553
+ }
554
+ }
555
+ routes['/dismiss-tour']({}, res)
556
+ })
448
557
  })
449
558
 
450
559
  describe('mode endpoint', () => {
@@ -495,6 +604,51 @@ describe('mode endpoint', () => {
495
604
  })
496
605
  })
497
606
 
607
+ describe('route access', () => {
608
+ // Records each route with the level it is registered at: 'admin' for a
609
+ // route registered on the router itself, the access() level otherwise.
610
+ function recordingRouter({ withAccess }) {
611
+ const levels = {}
612
+ const registrar = (level) => ({
613
+ get: (path) => {
614
+ levels[`GET ${path}`] = level
615
+ },
616
+ post: (path) => {
617
+ levels[`POST ${path}`] = level
618
+ }
619
+ })
620
+ const router = registrar('admin')
621
+ if (withAccess) router.access = registrar
622
+ return { router, levels }
623
+ }
624
+
625
+ it('opens /settings and /mode to readonly users and /dismiss-tour to readwrite users', () => {
626
+ const plugin = pluginFactory(createMockApp())
627
+ const { router, levels } = recordingRouter({ withAccess: true })
628
+ plugin.registerWithRouter(router)
629
+
630
+ assert.deepEqual(levels, {
631
+ 'GET /settings': 'readonly',
632
+ 'GET /webapps': 'admin',
633
+ 'GET /mode': 'readonly',
634
+ 'POST /dismiss-tour': 'readwrite'
635
+ })
636
+ })
637
+
638
+ it('registers every route on the router itself when it has no access()', () => {
639
+ const plugin = pluginFactory(createMockApp())
640
+ const { router, levels } = recordingRouter({ withAccess: false })
641
+ plugin.registerWithRouter(router)
642
+
643
+ assert.deepEqual(levels, {
644
+ 'GET /settings': 'admin',
645
+ 'GET /webapps': 'admin',
646
+ 'GET /mode': 'admin',
647
+ 'POST /dismiss-tour': 'admin'
648
+ })
649
+ })
650
+ })
651
+
498
652
  describe('public files', () => {
499
653
  const publicDir = path.join(__dirname, '..', 'public')
500
654