From: Oliver Sluke <22557015+oliversluke@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:49:14 +0000 (+0200) Subject: api: add access/whoami returning the session's access info, API v20 (#2201) X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=6453b4591f5754efc018bc24326beabf0edf8779;p=thirdparty%2Ftvheadend.git api: add access/whoami returning the session's access info, API v20 (#2201) The session's resolved permissions and UI preferences were only available via the comet "accessUpdate" notification, which the server emits at WebSocket-connect time — 100-500 ms after page load. The web UI therefore stalled its route guards on the first message, painted the default theme until it arrived, and had to force a full page reload after saving any UI preference (theme, UI level, quicktips, …) because a running session never receives a fresh copy. Add api/access/whoami (API v20): the same payload as accessUpdate, built by the shared comet_access_info_build() helper and available as a plain synchronous request. The comet notification is unchanged (the "address" field stays comet-only — the API layer has no connection handle). The new UI hydrates the access store from the endpoint before mounting — correct theme, UI level and permissions on first paint — and re-pulls it after saving an access-backed preference so theme and level changes now apply live; only a UI-language change still reloads (the locale catalog is loaded once at bootstrap). Comet remains the live-update channel and pre-v20 responses are ignored gracefully. Signed-off-by: Oliver Sluke <22557015+oliversluke@users.noreply.github.com> Co-authored-by: Flole --- diff --git a/src/api.h b/src/api.h index 5f651c99d..bcd9e2403 100644 --- a/src/api.h +++ b/src/api.h @@ -25,7 +25,6 @@ #include "redblack.h" #include "access.h" -/* 20: idnode numeric filter gained the ge / le / ne comparators */ #define TVH_API_VERSION 20 /* diff --git a/src/api/api_access.c b/src/api/api_access.c index 24279b97b..1263ee660 100644 --- a/src/api/api_access.c +++ b/src/api/api_access.c @@ -20,6 +20,7 @@ #include "tvheadend.h" #include "access.h" #include "api.h" +#include "webui/webui.h" /* * @@ -157,6 +158,19 @@ api_access_entry_create return 0; } +/* The session's resolved access rights and UI preferences — the same + * shape the comet "accessUpdate" notification carries, available as a + * plain synchronous request so clients don't have to wait for the + * first comet message. The "address" field is comet-only (no + * connection handle here). API version 20. */ +static int +api_access_whoami + ( access_t *perm, void *opaque, const char *op, htsmsg_t *args, htsmsg_t **resp ) +{ + *resp = comet_access_info_build(perm, NULL); + return 0; +} + void api_access_init ( void ) { static api_hook_t ah[] = { @@ -173,6 +187,8 @@ void api_access_init ( void ) { "access/entry/grid", ACCESS_ADMIN, api_idnode_grid, api_access_entry_grid }, { "access/entry/create", ACCESS_ADMIN, api_access_entry_create, NULL }, + { "access/whoami", ACCESS_WEB_INTERFACE, api_access_whoami, NULL }, + { NULL }, }; diff --git a/src/webui/comet.c b/src/webui/comet.c index b115fefdb..d065ca634 100644 --- a/src/webui/comet.c +++ b/src/webui/comet.c @@ -148,27 +148,29 @@ comet_mailbox_create(const char *lang) } /** - * + * Build the session's access / UI-preference info message. Shared by + * the comet "accessUpdate" notification and the api/access/whoami + * endpoint so both emit the same shape. `peer_ipstr` may be NULL + * (the API path has no connection handle); the "address" field is + * omitted then. */ -static void -comet_access_update(http_connection_t *hc, comet_mailbox_t *cmb) +htsmsg_t * +comet_access_info_build(struct access *aa, const char *peer_ipstr) { extern int access_noacl; htsmsg_t *m = htsmsg_create_map(); const char *username = ""; int64_t bfree, bused, btotal; - int dvr = !http_access_verify(hc, ACCESS_RECORDER); - int admin = !http_access_verify(hc, ACCESS_ADMIN); + int dvr = aa ? !access_verify2(aa, ACCESS_RECORDER) : 0; + int admin = aa ? !access_verify2(aa, ACCESS_ADMIN) : 0; const char *s; uint32_t default_tab = config.default_tab; - htsmsg_add_str(m, "notificationClass", "accessUpdate"); - - if (hc->hc_access) { - username = hc->hc_access->aa_username ?: ""; + if (aa) { + username = aa->aa_username ?: ""; - switch (hc->hc_access->aa_uilevel) { + switch (aa->aa_uilevel) { case UILEVEL_BASIC: s = "basic"; break; case UILEVEL_ADVANCED: s = "advanced"; break; case UILEVEL_EXPERT: s = "expert"; break; @@ -179,14 +181,14 @@ comet_access_update(http_connection_t *hc, comet_mailbox_t *cmb) if (config.uilevel_nochange) htsmsg_add_u32(m, "uilevel_nochange", config.uilevel_nochange); } - - if(hc->hc_access->aa_default_tab != CONFIG_DEFAULT_TAB_SYSTEM) + + if(aa->aa_default_tab != CONFIG_DEFAULT_TAB_SYSTEM) { - default_tab = hc->hc_access->aa_default_tab; + default_tab = aa->aa_default_tab; } } - htsmsg_add_str(m, "theme", access_get_theme(hc->hc_access)); + htsmsg_add_str(m, "theme", access_get_theme(aa)); htsmsg_add_u32(m, "page_size", config.page_size_ui); htsmsg_add_u32(m, "quicktips", config.ui_quicktips); htsmsg_add_u32(m, "chname_num", config.chname_num); @@ -197,8 +199,8 @@ comet_access_update(http_connection_t *hc, comet_mailbox_t *cmb) htsmsg_add_u32(m, "dvr_show_seconds", config.dvr_show_seconds); if (!access_noacl) htsmsg_add_str(m, "username", username); - if (hc->hc_peer_ipstr) - htsmsg_add_str(m, "address", hc->hc_peer_ipstr); + if (peer_ipstr) + htsmsg_add_str(m, "address", peer_ipstr); htsmsg_add_u32(m, "dvr", dvr); htsmsg_add_u32(m, "admin", admin); @@ -222,6 +224,19 @@ comet_access_update(http_connection_t *hc, comet_mailbox_t *cmb) if (admin && config.wizard) htsmsg_add_str(m, "wizard", config.wizard); + return m; +} + +/** + * + */ +static void +comet_access_update(http_connection_t *hc, comet_mailbox_t *cmb) +{ + htsmsg_t *m = comet_access_info_build(hc->hc_access, hc->hc_peer_ipstr); + + htsmsg_add_str(m, "notificationClass", "accessUpdate"); + if(cmb->cmb_messages == NULL) cmb->cmb_messages = htsmsg_create_list(); htsmsg_add_msg(cmb->cmb_messages, NULL, m); diff --git a/src/webui/static-vue/src/components/IdnodeConfigForm.vue b/src/webui/static-vue/src/components/IdnodeConfigForm.vue index df4237e9f..c4f72cf58 100644 --- a/src/webui/static-vue/src/components/IdnodeConfigForm.vue +++ b/src/webui/static-vue/src/components/IdnodeConfigForm.vue @@ -80,12 +80,19 @@ const props = withDefaults( * exclusive with `loadEndpoint` / `saveEndpoint`. */ uuid?: string | null /* Field ids whose change forces `globalThis.location.reload()` - * after a successful save — for fields whose value rides the - * Comet `accessUpdate` notification only at WS-connect time - * (e.g., the global config UI prefs). Default empty: page- - * specific configs (Image Cache, SAT>IP) don't ride - * accessUpdate. */ + * after a successful save — reserved for fields whose effect + * cannot be applied in-place (e.g. `language_ui`, which re-bakes + * every translated string from /locale.js). Default empty: + * page-specific configs (Image Cache, SAT>IP) don't need it. */ reloadFields?: readonly string[] + /* Field ids whose change triggers an `access/whoami` refetch + * after a successful save — for access-store-backed UI prefs + * (theme, uilevel, quicktips, …). The store's reactive + * consumers apply the new values live, replacing the + * pre-whoami full-page reload (Comet re-sends accessUpdate + * only on reconnect, so a refetch is the push we don't get). + * Checked only when no reloadFields entry matched. */ + accessRefetchFields?: readonly string[] /* Pin the displayed view level for this page and hide the * `` chooser entirely. Use when every field on the * page is gated at a single server-side `PO_*` level and there @@ -218,6 +225,7 @@ const props = withDefaults( saveEndpoint: undefined, uuid: undefined, reloadFields: () => [], + accessRefetchFields: () => [], lockLevel: undefined, disabledFor: undefined, saveLabel: undefined, @@ -690,11 +698,15 @@ async function save() { saving.value = true error.value = null try { - /* Snapshot the reload-trigger decision against the PRE-save - * baseline before the api call mutates anything we're tracking. */ + /* Snapshot the reload / access-refetch decisions against the + * PRE-save baseline before the api call mutates anything we're + * tracking. */ const needsReload = props.reloadFields.some( (k) => currentValues.value[k] !== baseline.value[k] ) + const needsAccessRefetch = props.accessRefetchFields.some( + (k) => currentValues.value[k] !== baseline.value[k] + ) /* uuid mode: idnode/save with the uuid baked into the node * payload — server's `idnode/save` handler reads `uuid` out of @@ -724,13 +736,21 @@ async function save() { emit('saved') if (needsReload) { - /* Forced reload mirrors ExtJS's postsave behaviour. The + /* Forced reload — reserved for changes the SPA can't apply + * in place (language_ui re-bakes /locale.js strings). The * reconnect's first accessUpdate carries fresh values for * the affected user-pref fields. */ globalThis.location.reload() return } + if (needsAccessRefetch) { + /* Access-store-backed UI prefs (theme, uilevel, quicktips, + * …): re-pull `access/whoami` so the store's reactive + * consumers apply the change live — no page reload. */ + await access.preloadFromHttp() + } + /* Refresh from server so baseline + currentValues snap to the * persisted values (in case the server normalised anything we * sent). */ diff --git a/src/webui/static-vue/src/components/__tests__/IdnodeConfigForm.test.ts b/src/webui/static-vue/src/components/__tests__/IdnodeConfigForm.test.ts index 6204f4d75..3d6717fe6 100644 --- a/src/webui/static-vue/src/components/__tests__/IdnodeConfigForm.test.ts +++ b/src/webui/static-vue/src/components/__tests__/IdnodeConfigForm.test.ts @@ -1121,3 +1121,55 @@ describe('IdnodeConfigForm — hash-driven field focus', () => { expect(wrapper.find('#field-expert_only').exists()).toBe(false) }) }) + +describe('IdnodeConfigForm — access refetch on save', () => { + /* + * Access-store-backed UI prefs (theme, uilevel, quicktips, …) no + * longer force a page reload on save: a changed accessRefetchFields + * entry re-pulls `access/whoami` so the store's reactive consumers + * apply the new value live. (The reloadFields path remains for + * language_ui.) + */ + it('re-pulls access/whoami after saving a changed access-backed pref', async () => { + const access = useAccessStore() + access.data = { admin: true, dvr: true, uilevel: 'expert' } + + const wrapper = await mountWithParams( + [{ id: 'theme_ui', type: 'str', caption: 'Theme', value: 'blue' }], + { accessRefetchFields: ['theme_ui'] } as never + ) + /* Subsequent calls: config/save, access/whoami, config/load. */ + apiMock.mockResolvedValue({ entries: [{ params: [] }] }) + + await wrapper.find('input[type="text"]').setValue('access') + await wrapper.find('.idnode-config-form__btn--save').trigger('click') + await flushPromises() + + const endpoints = apiMock.mock.calls.map((c) => c[0]) + expect(endpoints).toContain('imagecache/config/save') + expect(endpoints).toContain('access/whoami') + }) + + it('does not refetch when no access-backed pref changed', async () => { + const access = useAccessStore() + access.data = { admin: true, dvr: true, uilevel: 'expert' } + + const wrapper = await mountWithParams( + [ + { id: 'theme_ui', type: 'str', caption: 'Theme', value: 'blue' }, + { id: 'name', type: 'str', caption: 'Name', value: '' }, + ], + { accessRefetchFields: ['theme_ui'] } as never + ) + apiMock.mockResolvedValue({ entries: [{ params: [] }] }) + + /* Change only the non-access field (the second text input). */ + await wrapper.findAll('input[type="text"]')[1].setValue('x') + await wrapper.find('.idnode-config-form__btn--save').trigger('click') + await flushPromises() + + const endpoints = apiMock.mock.calls.map((c) => c[0]) + expect(endpoints).toContain('imagecache/config/save') + expect(endpoints).not.toContain('access/whoami') + }) +}) diff --git a/src/webui/static-vue/src/main.ts b/src/webui/static-vue/src/main.ts index 295e327d6..a346a8604 100644 --- a/src/webui/static-vue/src/main.ts +++ b/src/webui/static-vue/src/main.ts @@ -150,12 +150,13 @@ async function bootstrap() { */ /* - * No-op stub today (see stores/access.ts). When the upstream PR for - * `/api/access/whoami` lands, this awaits the synchronous fetch and - * the SPA mounts with access already populated — eliminating the - * router-guard wait on direct-URL navigation to gated routes. We call - * it BEFORE comet.connect() so the HTTP path wins the race in the - * common case; Comet still connects and runs the live-update channel. + * Hydrate access via `api/access/whoami` (API v20) so the SPA + * mounts with permissions, theme, uilevel and page-size already + * populated — no router-guard wait on direct-URL navigation, no + * pre-Comet theme flash. Called BEFORE comet.connect() so the HTTP + * path wins the race; Comet still connects and runs the live-update + * channel. On a pre-v20 server this 404s silently and Comet's first + * accessUpdate populates the store as before (see stores/access.ts). */ await access.preloadFromHttp() diff --git a/src/webui/static-vue/src/stores/__tests__/access.test.ts b/src/webui/static-vue/src/stores/__tests__/access.test.ts new file mode 100644 index 000000000..612b3c11b --- /dev/null +++ b/src/webui/static-vue/src/stores/__tests__/access.test.ts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Tvheadend contributors + +/* + * Access store — the two fill paths. `preloadFromHttp()` hydrates from + * `api/access/whoami` (API v20) before the SPA mounts; Comet's + * `accessUpdate` remains the live-update channel and the only path on + * pre-v20 servers (where whoami 404s and must be swallowed). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { nextTick } from 'vue' + +const h = vi.hoisted(() => ({ + apiCall: vi.fn(), + listeners: new Map void>(), +})) + +vi.mock('@/api/client', () => ({ + apiCall: (...args: unknown[]) => h.apiCall(...args), +})) +vi.mock('@/api/comet', () => ({ + cometClient: { + on: (cls: string, fn: (msg: unknown) => void) => { + h.listeners.set(cls, fn) + return () => h.listeners.delete(cls) + }, + }, +})) + +import { useAccessStore } from '../access' + +const WHOAMI = { + username: 'alice', + admin: 1, + dvr: 1, + uilevel: 'expert', + theme: 'access', + page_size: 50, +} + +beforeEach(() => { + setActivePinia(createPinia()) + h.apiCall.mockReset() + h.listeners.clear() + delete document.documentElement.dataset.theme +}) + +describe('access store — preloadFromHttp', () => { + it('hydrates the store from access/whoami before Comet', async () => { + h.apiCall.mockResolvedValue(WHOAMI) + const store = useAccessStore() + expect(store.loaded).toBe(false) + + await store.preloadFromHttp() + + expect(h.apiCall).toHaveBeenCalledWith('access/whoami') + expect(store.loaded).toBe(true) + expect(store.uilevel).toBe('expert') + expect(store.has('admin')).toBe(true) + expect(store.authMode).toBe('authenticated') + /* The theme watcher applies the server theme without waiting + * for the first Comet message — no blue flash. */ + await nextTick() + expect(document.documentElement.dataset.theme).toBe('access') + }) + + it('swallows failures on pre-v20 servers and leaves Comet in charge', async () => { + h.apiCall.mockRejectedValue(new Error('404')) + const store = useAccessStore() + + await expect(store.preloadFromHttp()).resolves.toBeUndefined() + expect(store.loaded).toBe(false) + + /* Comet's accessUpdate still populates the store as before. */ + h.listeners.get('accessUpdate')?.({ + notificationClass: 'accessUpdate', + ...WHOAMI, + uilevel: 'advanced', + }) + expect(store.loaded).toBe(true) + expect(store.uilevel).toBe('advanced') + }) + + it('lets a later Comet accessUpdate overwrite the preloaded state', async () => { + h.apiCall.mockResolvedValue(WHOAMI) + const store = useAccessStore() + await store.preloadFromHttp() + + h.listeners.get('accessUpdate')?.({ + notificationClass: 'accessUpdate', + ...WHOAMI, + theme: 'gray', + }) + await nextTick() + expect(document.documentElement.dataset.theme).toBe('gray') + }) +}) diff --git a/src/webui/static-vue/src/stores/access.ts b/src/webui/static-vue/src/stores/access.ts index 34b618350..e5785f369 100644 --- a/src/webui/static-vue/src/stores/access.ts +++ b/src/webui/static-vue/src/stores/access.ts @@ -3,56 +3,53 @@ /* * Access store — the user's permissions and UI preferences as pushed - * by the server. Populated and updated entirely via Comet (no separate - * HTTP fetch); the first `accessUpdate` message arrives within ~500ms - * of WebSocket connect (see comet_find_mailbox in src/webui/comet.c — - * the server sends accessUpdate as part of mailbox creation). + * by the server. Two fill paths, one source of truth: + * - `preloadFromHttp()` fetches `api/access/whoami` (API v20) during + * bootstrap, BEFORE the SPA mounts — so the theme, uilevel, + * permissions and page-size are correct on first paint. + * - Comet's `accessUpdate` (the same message shape) arrives with the + * mailbox handshake and on every reconnect, wholesale-replacing + * the store — the live-update channel. + * On a pre-v20 server the whoami fetch 404s and is ignored; Comet then + * populates the store exactly as before. */ import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' import type { Access, AuthMode, PermissionKey, UiLevel } from '@/types/access' import type { NotificationMessage } from '@/types/comet' +import { apiCall } from '@/api/client' import { cometClient } from '@/api/comet' -/* - * Forward-looking stub for instant boot once the server exposes a - * synchronous "who am I?" HTTP endpoint. - * - * PROBLEM TODAY: the access object is only delivered via Comet's first - * `accessUpdate` message, which arrives 100-500ms after the WebSocket - * handshake (or worse if the network is hostile). Direct-URL navigation - * to a permission-gated route therefore needs to wait for that message - * before the router guard can decide; see router/index.ts beforeEach. - * - * PLANNED UPSTREAM PR — adds `/api/access/whoami`: - * - src/api/api_access.c: register endpoint with ACCESS_WEB_INTERFACE, - * handler reuses the same field-by-field population logic that - * comet_access_update() in src/webui/comet.c uses (refactor that - * into a shared helper that takes an htsmsg_t * to populate). - * - Returns the same JSON shape as the Comet `accessUpdate` notification - * (minus `notificationClass`). - * - Roughly 30 lines of C plus a property-table row, no idnode work. - * - General-purpose: also useful for test scripts and non-web clients. - * - * WHEN THE PR LANDS: take the store handle as an argument (so this can - * write `data`/`loaded`), import apiCall from '@/api/client', call - * `apiCall('access/whoami')`, populate on success, and ignore - * errors (fall through to Comet). main.ts already invokes - * preloadFromHttp() during bootstrap; it's currently a no-op. Once the - * endpoint exists, access is hydrated before the SPA mounts and the - * router guard's await never fires for typical navigation. Comet still - * runs and overwrites the store with live updates — single source of - * truth, just two paths to fill it. - */ -async function preloadFromHttp() { - /* No-op until /api/access/whoami exists upstream. */ -} - export const useAccessStore = defineStore('access', () => { const data = ref(null) const loaded = ref(false) + /* + * Synchronous-boot hydration via `api/access/whoami` — the same + * payload the comet `accessUpdate` carries (built by the shared + * `comet_access_info_build()` server-side), minus the comet-only + * `address` field, which the first accessUpdate supplies moments + * later. main.ts awaits this BEFORE `comet.connect()` and before + * `app.mount()`, so on a v20+ server the router guards, the theme + * watcher and the first grid fetch all see real access data + * instead of waiting ~100-500 ms for the WebSocket round-trip. + * + * Errors are swallowed by design: a pre-v20 server 404s here and + * the store simply stays empty until Comet's first accessUpdate — + * the exact pre-whoami behaviour. Also reusable later as a + * "refresh now" primitive (e.g. after saving General settings) + * since Comet only re-sends accessUpdate on reconnect. + */ + async function preloadFromHttp(): Promise { + try { + data.value = await apiCall('access/whoami') + loaded.value = true + } catch { + /* Pre-v20 server or transient failure — fall through to Comet. */ + } + } + /* * View-level surface — surfaces the two server-side fields that drive * the Basic / Advanced / Expert filtering subsystem. diff --git a/src/webui/static-vue/src/views/configuration/ConfigGeneralBaseView.vue b/src/webui/static-vue/src/views/configuration/ConfigGeneralBaseView.vue index 934f3f484..ae300d15f 100644 --- a/src/webui/static-vue/src/views/configuration/ConfigGeneralBaseView.vue +++ b/src/webui/static-vue/src/views/configuration/ConfigGeneralBaseView.vue @@ -13,14 +13,13 @@ * * - Endpoints `config/load` + `config/save` (the global config * idnode). - * - `RELOAD_FIELDS` lists the field ids whose change forces - * `globalThis.location.reload()` after Save. They ride the - * Comet `accessUpdate` notification, which is emitted only at - * WS-connect time (comet.c:154-200), so an existing session's - * cached value would otherwise be stale until manual refresh. - * ExtJS handles this identically — config.js:35-61. The proper - * fix is server-side (push fresh `accessUpdate` when these - * change, or split the notification class). + * - `ACCESS_REFETCH_FIELDS` lists the access-store-backed UI + * prefs: a save that changes one re-pulls `api/access/whoami` + * and the store's reactive consumers apply the change live. + * `RELOAD_FIELDS` keeps the full-reload path for the one field + * the SPA can't apply in place (`language_ui` — /locale.js is + * loaded once at bootstrap). ExtJS still hard-reloads for all + * of them — config.js:35-61. * - Start wizard button — admin-only toolbar action mirroring * legacy ExtJS at `static/app/config.js:7-24`. POSTs * `api/wizard/start` (ACCESS_ADMIN per @@ -56,39 +55,31 @@ async function startWizard() { } } -const RELOAD_FIELDS: readonly string[] = [ +/* Fields whose change genuinely needs a full page reload: a UI + * language switch re-bakes every translated string from /locale.js, + * which the SPA loads once at bootstrap. */ +const RELOAD_FIELDS: readonly string[] = ['language_ui'] + +/* Access-store-backed UI prefs — a save that changes any of these + * re-pulls `api/access/whoami` and the store's reactive consumers + * apply the new values live (theme watcher, uilevel filtering, + * quicktips gating, NavRail footer items, PT_TIME seconds, date + * mask, …). Pre-whoami these forced a full reload because their + * values ride the Comet `accessUpdate`, which the server emits + * only at WS-connect time. `default_tab` only matters on the next + * cold load anyway; `page_size_ui` is read at store init. Both are + * refreshed along for consistency. */ +const ACCESS_REFETCH_FIELDS: readonly string[] = [ 'uilevel', 'theme_ui', 'page_size_ui', 'uilevel_nochange', 'ui_quicktips', - 'language_ui', - /* Drives the NavRail's footer item set + ordering. Same - * WS-connect-only `accessUpdate` propagation issue as the - * others above, so a save needs to force a fresh connect via - * reload. */ 'info_area', - /* Drives the EPG view-options Number-checkbox default + the - * EPG Table view's Channel column rendering. Same WS-connect- - * only propagation gap. */ 'chname_num', - /* Drives the source-prefix on channel display strings (e.g. - * "DVB-T: Channel One" instead of "Channel One") for both editor - * dropdowns and EnumNameCell-rendered grid cells. Same - * WS-connect-only propagation gap. */ 'chname_src', - /* Drives whether idnode PT_TIME edit fields expose seconds. - * Read by IdnodeFieldTime via useAccessStore. Same - * WS-connect-only propagation gap. */ 'dvr_show_seconds', - /* Drives `fmtDate`'s custom-format branch on desktop (grid - * cells, qtips, etc.). Read from the access store at call - * time; same accessUpdate-on-connect propagation issue. */ 'date_mask', - /* Drives the cold-load default_tab redirect in the router. - * Same accessUpdate-on-connect propagation issue; also the - * value is sessionStorage-deduped, so changing it without a - * reload would have no effect until the next tab open. */ 'default_tab', ] @@ -122,6 +113,7 @@ const MANDATORY_FIELDS: readonly string[] = [ help-page="class/config" save-endpoint="config/save" :reload-fields="RELOAD_FIELDS" + :access-refetch-fields="ACCESS_REFETCH_FIELDS" :mandatory-fields="MANDATORY_FIELDS" >