]> git.ipfire.org Git - thirdparty/tvheadend.git/commitdiff
api: add access/whoami returning the session's access info, API v20 (#2201)
authorOliver Sluke <22557015+oliversluke@users.noreply.github.com>
Sun, 26 Jul 2026 17:49:14 +0000 (19:49 +0200)
committerGitHub <noreply@github.com>
Sun, 26 Jul 2026 17:49:14 +0000 (19:49 +0200)
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 <Flole998@users.noreply.github.com>
src/api.h
src/api/api_access.c
src/webui/comet.c
src/webui/static-vue/src/components/IdnodeConfigForm.vue
src/webui/static-vue/src/components/__tests__/IdnodeConfigForm.test.ts
src/webui/static-vue/src/main.ts
src/webui/static-vue/src/stores/__tests__/access.test.ts [new file with mode: 0644]
src/webui/static-vue/src/stores/access.ts
src/webui/static-vue/src/views/configuration/ConfigGeneralBaseView.vue
src/webui/webui.h

index 5f651c99def890e2470334aa0853f9fe6651216d..bcd9e240335a83384591150dc901994f5c1a01cc 100644 (file)
--- 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
 
 /*
index 24279b97bb3817018fd11d6c36e5058ad787db43..1263ee6609a7f15365ade46c3c74426edb859992 100644 (file)
@@ -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 },
   };
 
index b115fefdb9bd1e2a5ff502eed4d7c48ab4dc0542..d065ca63464c29700c9b218dc99a1d088c628860 100644 (file)
@@ -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);
index df4237e9f1555e69f61a6f5ce9e985ca49f5e009..c4f72cf58162b7a65c492c8d94a203b27aa51a80 100644 (file)
@@ -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
      * `<LevelMenu>` 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). */
index 6204f4d752721a94f0e830ca1ae86585df389f63..3d6717fe66589ba80fb6452a447493b4822c8dee 100644 (file)
@@ -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')
+  })
+})
index 295e327d67ed5a94c73bd7402476c86eea538587..a346a8604a2cdf7bde5ce9080053257a69166188 100644 (file)
@@ -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 (file)
index 0000000..612b3c1
--- /dev/null
@@ -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<string, (msg: unknown) => 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')
+  })
+})
index 34b6183507c8c8f6403fb23b7466b3ae91d64e68..e5785f3696ad42a39b1d8d64cb9afa66730d1190 100644 (file)
@@ -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>('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<Access | null>(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<void> {
+    try {
+      data.value = await apiCall<Access>('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.
index 934f3f484b17992d8d1b89d887835658ef9654f0..ae300d15fa50adfa4e7221b413bf5fb490d80122 100644 (file)
  *
  *   - 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"
   >
     <template #actions="{ loading, saving }">
index 9808e7784f887cfac3fdf929aae55621d32278fa..1a5db28a3b888d4c79d43da8a1c9f9ff4e5603c9 100644 (file)
@@ -82,4 +82,9 @@ void comet_mailbox_add_logmsg(const char *txt, int isdebug, int rewrite);
 
 void comet_flush(void);
 
+/* Session access/UI-preference info message — shared by the comet
+ * "accessUpdate" notification and api/access/whoami. */
+struct access;
+htsmsg_t *comet_access_info_build(struct access *aa, const char *peer_ipstr);
+
 #endif /* WEBUI_H_ */