#include "redblack.h"
#include "access.h"
-/* 20: idnode numeric filter gained the ge / le / ne comparators */
#define TVH_API_VERSION 20
/*
#include "tvheadend.h"
#include "access.h"
#include "api.h"
+#include "webui/webui.h"
/*
*
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[] = {
{ "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 },
};
}
/**
- *
+ * 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;
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);
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);
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);
* 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
saveEndpoint: undefined,
uuid: undefined,
reloadFields: () => [],
+ accessRefetchFields: () => [],
lockLevel: undefined,
disabledFor: undefined,
saveLabel: undefined,
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
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). */
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')
+ })
+})
*/
/*
- * 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()
--- /dev/null
+// 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')
+ })
+})
/*
* 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.
*
* - 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
}
}
-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',
]
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 }">
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_ */