From 08c7b01effbcb0b5997dffa52febffe1a1e7fa1f Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 18:22:53 +0200 Subject: [PATCH 01/10] fix(surface-tabs): optional module-activation filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab descriptors may carry an optional 'module' field naming their owning optional module; resolveSurfaceTabs gains an optional isActive predicate (default keep-all) that hides tabs whose module is deactivated. Routes are already gated at injection time — this closes the matching render-time gap where a baked config tab for a deactivated module clicked through to a 404. refs #4295 --- src/lib/helpers/surface-tabs.js | 25 +++++++--- .../helpers/tests/surface-tabs.unit.tests.js | 46 +++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/lib/helpers/surface-tabs.js b/src/lib/helpers/surface-tabs.js index d003dc05f..82645bc13 100644 --- a/src/lib/helpers/surface-tabs.js +++ b/src/lib/helpers/surface-tabs.js @@ -53,22 +53,33 @@ export function isValidTab(tab) { } /** - * Filter a tabs array by validity then by CASL permissions. + * Filter a tabs array by validity, then by CASL permissions, then by module + * activation. * * A tab with no `{action, subject}` pair is always shown (unconditional tabs). * Both `action` AND `subject` must be present for the CASL check to apply. * - * Intended for use by surface layouts (org-settings, admin, etc.) to derive - * their reactive extra-tab list from a config array + a `can` predicate from - * `useAbility()`. + * A tab may carry an optional `module` field naming the optional module that + * owns it (e.g. a module-contributed `config.admin.tabs` entry). When the + * `isActive` predicate reports that module inactive, the tab is hidden — + * keeping render-time tab visibility consistent with route injection, which + * already skips deactivated modules (`injectModuleChildren`). Tabs without a + * `module` field are never activation-filtered, and the keep-all default + * preserves existing 2-argument callers byte-for-byte. + * + * Intended for use by surface layouts (org-settings, admin, account, etc.) + * to derive their reactive extra-tab list from a config array + a `can` + * predicate from `useAbility()` (+ optionally `isModuleActive`). * * @param {Array|null|undefined} tabs - Raw tabs from config. * @param {(action: string, subject: string) => boolean} can - Required. CASL predicate from `useAbility()`. Must be a function. - * @returns {Array} Validated + permitted tabs. + * @param {(moduleName: string) => boolean} [isActive=() => true] - Optional module-activation predicate (e.g. `isModuleActive`). Defaults to keep-all. + * @returns {Array} Validated + permitted + activation-filtered tabs. */ -export function resolveSurfaceTabs(tabs, can) { +export function resolveSurfaceTabs(tabs, can, isActive = () => true) { if (typeof can !== 'function') throw new TypeError('[resolveSurfaceTabs] can must be a function'); return (Array.isArray(tabs) ? tabs : []) .filter(isValidTab) - .filter((t) => (t.action && t.subject ? can(t.action, t.subject) : true)); + .filter((t) => (t.action && t.subject ? can(t.action, t.subject) : true)) + .filter((t) => (t.module ? isActive(t.module) : true)); } diff --git a/src/lib/helpers/tests/surface-tabs.unit.tests.js b/src/lib/helpers/tests/surface-tabs.unit.tests.js index 2e0f9f188..a4d691d48 100644 --- a/src/lib/helpers/tests/surface-tabs.unit.tests.js +++ b/src/lib/helpers/tests/surface-tabs.unit.tests.js @@ -182,3 +182,49 @@ describe('resolveSurfaceTabs', () => { expect(resolveSurfaceTabs(tabs, can).map((t) => t.value)).toEqual(['billing']); }); }); + +/** + * resolveSurfaceTabs — optional module-activation filter (3rd param). + * A tab may carry an optional `module` field naming its owning optional + * module; when the `isActive` predicate reports that module inactive the tab + * is hidden. Tabs without a `module` field are never activation-filtered, + * and omitting `isActive` keeps every tab (backward compatible default). + */ +describe('resolveSurfaceTabs — module activation filter', () => { + const allowAll = () => true; + const tabs = [ + { value: 'profile', label: 'Profile', route: 'profile' }, + { value: 'invitations', label: 'Referrals', route: 'invitations', module: 'invitations' }, + ]; + + it('filters out a tab whose module is inactive', () => { + const isActive = (name) => name !== 'invitations'; + expect(resolveSurfaceTabs(tabs, allowAll, isActive).map((t) => t.value)).toEqual(['profile']); + }); + + it('keeps a tab whose module is active', () => { + const isActive = () => true; + expect(resolveSurfaceTabs(tabs, allowAll, isActive).map((t) => t.value)).toEqual(['profile', 'invitations']); + }); + + it('never activation-filters tabs without a module field, even when isActive rejects everything', () => { + const isActive = () => false; + expect(resolveSurfaceTabs(tabs, allowAll, isActive).map((t) => t.value)).toEqual(['profile']); + }); + + it('keeps all tabs when isActive is omitted (backward-compatible default)', () => { + expect(resolveSurfaceTabs(tabs, allowAll).map((t) => t.value)).toEqual(['profile', 'invitations']); + }); + + it('applies validity + CASL + activation in one pass', () => { + const mixed = [ + { value: 'profile', label: 'Profile', route: 'profile' }, + { value: 'denied', label: 'Denied', route: 'denied', action: 'manage', subject: 'Nope' }, + { value: 'invitations', label: 'Referrals', route: 'invitations', module: 'invitations' }, + { label: 'broken' }, + ]; + const can = (action, subject) => subject !== 'Nope'; + const isActive = (name) => name !== 'invitations'; + expect(resolveSurfaceTabs(mixed, can, isActive).map((t) => t.value)).toEqual(['profile']); + }); +}); From 9c77129142b2f11532fe8b7341badba66078310c Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 18:24:16 +0200 Subject: [PATCH 02/10] fix(invitations): hide module-owned tabs when deactivated Tag the admin Invitations + account Referrals tab descriptors with module: 'invitations' and pass isModuleActive as resolveSurfaceTabs' third argument from CoreSurfaceTabBar (the single call site, so every surface gets the gate). Regenerates src/config/index.js (gitignored build artifact). Render-time filtering keeps one source of truth (isModuleActive) instead of pruning at generation time across the 5 merge layers. refs #4295 --- .../components/core.surfaceTabBar.component.vue | 16 ++++++++++------ .../config/invitations.development.config.js | 9 +++++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/modules/core/components/core.surfaceTabBar.component.vue b/src/modules/core/components/core.surfaceTabBar.component.vue index 7c86a8f4f..592bb68ed 100644 --- a/src/modules/core/components/core.surfaceTabBar.component.vue +++ b/src/modules/core/components/core.surfaceTabBar.component.vue @@ -1,18 +1,22 @@ diff --git a/src/modules/auth/tests/auth.adminMailerWarning.component.unit.tests.js b/src/modules/auth/tests/auth.adminMailerWarning.component.unit.tests.js deleted file mode 100644 index 70d4a974c..000000000 --- a/src/modules/auth/tests/auth.adminMailerWarning.component.unit.tests.js +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { createPinia, setActivePinia } from 'pinia'; -import { createVuetify } from 'vuetify'; - -// v-snackbar uses visualViewport which is not available in jsdom -if (typeof globalThis.visualViewport === 'undefined') { - globalThis.visualViewport = { addEventListener: vi.fn(), removeEventListener: vi.fn(), width: 1024, height: 768 }; -} - -const storeMock = vi.hoisted(() => ({ - isLoggedIn: false, - user: null, - serverConfig: null, -})); - -vi.mock('../stores/auth.store', () => ({ - useAuthStore: () => storeMock, -})); - -import AuthAdminMailerWarning from '../components/auth.adminMailerWarning.component.vue'; - -/** - * Mount the admin mailer warning component with Vuetify installed. - * @returns {import('@vue/test-utils').VueWrapper} mounted wrapper - */ -const mountComponent = () => - mount(AuthAdminMailerWarning, { - global: { - plugins: [createVuetify()], - }, - }); - -describe('auth.adminMailerWarning.component', () => { - beforeEach(() => { - setActivePinia(createPinia()); - storeMock.isLoggedIn = false; - storeMock.user = null; - storeMock.serverConfig = null; - sessionStorage.removeItem('adminMailerWarningDismissed'); - }); - - it('does not show when user is not logged in', () => { - storeMock.isLoggedIn = false; - const wrapper = mountComponent(); - - expect(wrapper.vm.shouldShow).toBe(false); - }); - - it('does not show when user is not admin', () => { - storeMock.isLoggedIn = true; - storeMock.user = { roles: ['user'] }; - storeMock.serverConfig = { mail: { configured: false } }; - const wrapper = mountComponent(); - - expect(wrapper.vm.shouldShow).toBe(false); - }); - - it('does not show when mail is configured', () => { - storeMock.isLoggedIn = true; - storeMock.user = { roles: ['admin'] }; - storeMock.serverConfig = { mail: { configured: true } }; - const wrapper = mountComponent(); - - expect(wrapper.vm.shouldShow).toBe(false); - }); - - it('shows when admin is logged in and mail is not configured', () => { - storeMock.isLoggedIn = true; - storeMock.user = { roles: ['admin'] }; - storeMock.serverConfig = { mail: { configured: false } }; - const wrapper = mountComponent(); - - expect(wrapper.vm.shouldShow).toBe(true); - expect(wrapper.vm.visible).toBe(true); - }); - - it('does not show when serverConfig is null (config still loading)', () => { - storeMock.isLoggedIn = true; - storeMock.user = { roles: ['admin'] }; - storeMock.serverConfig = null; - const wrapper = mountComponent(); - - expect(wrapper.vm.shouldShow).toBe(false); - expect(wrapper.vm.visible).toBe(false); - }); - - it('hides when dismissed via dismiss() and persists in sessionStorage', () => { - storeMock.isLoggedIn = true; - storeMock.user = { roles: ['admin'] }; - storeMock.serverConfig = { mail: { configured: false } }; - const wrapper = mountComponent(); - - expect(wrapper.vm.visible).toBe(true); - - wrapper.vm.dismiss(); - - expect(wrapper.vm.visible).toBe(false); - expect(sessionStorage.getItem('adminMailerWarningDismissed')).toBe('true'); - }); - - it('hides when dismissed via visible setter (implicit close) and persists in sessionStorage', async () => { - storeMock.isLoggedIn = true; - storeMock.user = { roles: ['admin'] }; - storeMock.serverConfig = { mail: { configured: false } }; - const wrapper = mountComponent(); - - expect(wrapper.vm.visible).toBe(true); - - wrapper.vm.visible = false; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.visible).toBe(false); - expect(sessionStorage.getItem('adminMailerWarningDismissed')).toBe('true'); - }); -}); From 28692849723b728d46a2849e3f64fc93fb378b04 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 18:30:01 +0200 Subject: [PATCH 05/10] feat(admin): warning-count badge on the readiness tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive the non-ok readiness check count client-side from the admin store and decorate the built-in readiness tab descriptor with an optional numeric badge. CoreSurfaceTabBar renders it as a small tonal warning chip after the label — additive, tabs without a badge render exactly as before, so the account and organization surfaces sharing the component are unaffected. The layout fetches readiness on mount only when the store is empty and skips entirely when landing on the readiness tab, whose view already fetches on its own mount (child mounted runs first, so an empty-store check alone cannot prevent the double GET). refs pierreb-devkit/Node#3836 --- .../admin/tests/admin.layout.unit.tests.js | 40 ++++++++++++++++++- src/modules/admin/views/admin.layout.vue | 34 +++++++++++++++- .../core.surfaceTabBar.component.vue | 7 ++++ ...core.surfaceTabBar.component.unit.tests.js | 33 +++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/modules/admin/tests/admin.layout.unit.tests.js b/src/modules/admin/tests/admin.layout.unit.tests.js index 523414575..bda2ef73b 100644 --- a/src/modules/admin/tests/admin.layout.unit.tests.js +++ b/src/modules/admin/tests/admin.layout.unit.tests.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { createVuetify } from 'vuetify'; -const adminStoreState = { error: null, currentBreadcrumb: null }; +const adminStoreState = { error: null, currentBreadcrumb: null, readiness: [], getReadiness: vi.fn() }; const authStoreState = { serverConfig: null }; vi.mock('../stores/admin.store', () => ({ @@ -68,6 +68,8 @@ describe('admin.layout', () => { setActivePinia(createPinia()); adminStoreState.error = null; adminStoreState.currentBreadcrumb = null; + adminStoreState.readiness = []; + adminStoreState.getReadiness = vi.fn(); authStoreState.serverConfig = null; }); @@ -204,4 +206,40 @@ describe('admin.layout', () => { expect(wrapper.text()).toContain('Jane Doe'); }); + it('fetches readiness on mount when the store has none (feeds the tab badge)', () => { + mountLayout(); + expect(adminStoreState.getReadiness).toHaveBeenCalledTimes(1); + }); + + it('does not fetch readiness on mount when the store is already populated', () => { + adminStoreState.readiness = [{ category: 'database', status: 'ok', message: 'up' }]; + mountLayout(); + expect(adminStoreState.getReadiness).not.toHaveBeenCalled(); + }); + + it('does not fetch readiness when landing directly on the readiness tab (view fetches itself)', () => { + mountLayout({}, '/admin/readiness'); + expect(adminStoreState.getReadiness).not.toHaveBeenCalled(); + }); + + it('decorates the readiness tab with a badge equal to the non-ok check count', () => { + adminStoreState.readiness = [ + { category: 'database', status: 'ok', message: 'up' }, + { category: 'mailer', status: 'warning', message: 'not configured' }, + { category: 'storage', status: 'error', message: 'unreachable' }, + ]; + const wrapper = mountLayout(); + const tabs = wrapper.findComponent({ name: 'CorePageHeaderTabs' }).props('tabs'); + const readinessTab = tabs.find((t) => t.value === 'readiness'); + expect(readinessTab.badge).toBe(2); + }); + + it('adds no badge field when every readiness check is ok', () => { + adminStoreState.readiness = [{ category: 'database', status: 'ok', message: 'up' }]; + const wrapper = mountLayout(); + const tabs = wrapper.findComponent({ name: 'CorePageHeaderTabs' }).props('tabs'); + const readinessTab = tabs.find((t) => t.value === 'readiness'); + expect(readinessTab.badge).toBeUndefined(); + }); + }); diff --git a/src/modules/admin/views/admin.layout.vue b/src/modules/admin/views/admin.layout.vue index 7659a9ae2..7d67bbcc5 100644 --- a/src/modules/admin/views/admin.layout.vue +++ b/src/modules/admin/views/admin.layout.vue @@ -94,15 +94,31 @@ export default { currentBreadcrumb() { return useAdminStore().currentBreadcrumb; }, + /** + * @desc Number of readiness checks that are not OK — drives the badge + * on the built-in Readiness tab. + * @returns {number} + */ + readinessWarnings() { + const checks = useAdminStore().readiness; + if (!Array.isArray(checks)) return 0; + return checks.filter((c) => c && c.status !== 'ok').length; + }, /** * @desc Merged tab list (built-in + config-driven extras), passed to * CoreSurfaceTabBar. Validation and CASL filtering happen inside - * the bar via `resolveSurfaceTabs`. + * the bar via `resolveSurfaceTabs`. The built-in Readiness tab is + * decorated with an optional numeric `badge` (non-ok check count) + * that CoreSurfaceTabBar renders as a small warning chip. * @returns {Array} */ allTabs() { const extras = Array.isArray(this.config?.admin?.tabs) ? this.config.admin.tabs : []; - return [...BUILT_IN_TABS, ...extras]; + const tabs = [...BUILT_IN_TABS, ...extras]; + if (this.readinessWarnings > 0) { + return tabs.map((t) => (t.value === 'readiness' ? { ...t, badge: this.readinessWarnings } : t)); + } + return tabs; }, /** * @desc Reactive CASL predicate passed to CoreSurfaceTabBar. Falls back @@ -114,6 +130,20 @@ export default { return (action, subject) => (ability ? ability.can(action, subject) : true); }, }, + mounted() { + // Readiness data feeds the tab badge. The readiness view fetches on its + // own mount, and child mounted() runs BEFORE the parent's — so when the + // user lands directly on /admin/readiness a request is already in + // flight and an empty-store check alone would still double-hit + // GET /admin/readiness. Skip that route entirely; everywhere else, + // fetch once iff the store has no readiness data yet (fire-and-forget; + // the store sanitizes failures into its own `error` state). + if (this.$route?.path?.startsWith('/admin/readiness')) return; + const adminStore = useAdminStore(); + if (!Array.isArray(adminStore.readiness) || adminStore.readiness.length === 0) { + adminStore.getReadiness(); + } + }, methods: { /** * @desc Clear the global admin error banner. diff --git a/src/modules/core/components/core.surfaceTabBar.component.vue b/src/modules/core/components/core.surfaceTabBar.component.vue index 592bb68ed..e896ff517 100644 --- a/src/modules/core/components/core.surfaceTabBar.component.vue +++ b/src/modules/core/components/core.surfaceTabBar.component.vue @@ -68,6 +68,13 @@ function tabTo(route) { > {{ t.label }} + + + {{ t.badge }} + diff --git a/src/modules/core/components/tests/core.surfaceTabBar.component.unit.tests.js b/src/modules/core/components/tests/core.surfaceTabBar.component.unit.tests.js index 96f3b4cb5..70056d852 100644 --- a/src/modules/core/components/tests/core.surfaceTabBar.component.unit.tests.js +++ b/src/modules/core/components/tests/core.surfaceTabBar.component.unit.tests.js @@ -135,3 +135,36 @@ describe('SurfaceTabBar — path composition', () => { expect(wrapper.find('.v-tab-stub').attributes('href')).toBe('/admin/x'); }); }); + +describe('SurfaceTabBar — optional tab badge', () => { + it('renders a small tonal warning chip with the count after the label when badge > 0', () => { + const wrapper = mountBar({ + tabs: [{ value: 'readiness', label: 'Readiness', route: 'readiness', badge: 3 }], + can: () => true, + basePath: '/admin', + }); + const chip = wrapper.findComponent({ name: 'VChip' }); + expect(chip.exists()).toBe(true); + expect(chip.text()).toBe('3'); + expect(chip.props('color')).toBe('warning'); + expect(chip.props('variant')).toBe('tonal'); + }); + + it('renders no chip when badge is 0', () => { + const wrapper = mountBar({ + tabs: [{ value: 'readiness', label: 'Readiness', route: 'readiness', badge: 0 }], + can: () => true, + basePath: '/admin', + }); + expect(wrapper.findComponent({ name: 'VChip' }).exists()).toBe(false); + }); + + it('renders no chip when badge is absent (existing surfaces unaffected)', () => { + const wrapper = mountBar({ + tabs: [{ value: 'general', label: 'General', route: 'general' }], + can: () => true, + basePath: '/users/organizations/1', + }); + expect(wrapper.findComponent({ name: 'VChip' }).exists()).toBe(false); + }); +}); From 472638fdf78e9e1cbd1fc510e0377e4e2f657203 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 18:33:42 +0200 Subject: [PATCH 06/10] feat(admin): card-title search and top-right actions on activity tab Mirror the datatable card-title row on the Activity tab: title + spacer + Clear action + two compact outlined filter fields, debounced 1000ms like the datatable search (replaces the enter-key + Search-button flow). Gate the user-ID filter on a client-side ObjectId check since GET /audit 400s on malformed userId values; dedupe the trailing debounce so Clear does not double-fetch. max-width stays a Vuetify prop (no inline styles in this view). refs #4296 --- .../tests/admin.activity.view.unit.tests.js | 97 +++++++++++- .../admin/views/admin.activity.view.vue | 147 +++++++++++------- 2 files changed, 186 insertions(+), 58 deletions(-) diff --git a/src/modules/admin/tests/admin.activity.view.unit.tests.js b/src/modules/admin/tests/admin.activity.view.unit.tests.js index d0b1b5353..1021b5ecc 100644 --- a/src/modules/admin/tests/admin.activity.view.unit.tests.js +++ b/src/modules/admin/tests/admin.activity.view.unit.tests.js @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; import { shallowMount } from '@vue/test-utils'; import { useAdminStore } from '../stores/admin.store'; @@ -224,3 +224,98 @@ describe('admin.activity.view — template chrome', () => { expect(tmpl).toMatch(/@keydown\.space/); }); }); + +describe('admin.activity.view — debounced card-title filters', () => { + let adminStore; + + /** + * Mount with a fresh pinia + mocked store action. Call AFTER + * vi.useFakeTimers() so the debounce timer is controllable. + * @returns {import('@vue/test-utils').VueWrapper} + */ + const mountWithFreshStore = () => { + setActivePinia(createPinia()); + adminStore = useAdminStore(); + adminStore.getAuditLogs = vi.fn().mockResolvedValue(); + return mountActivity(); + }; + + afterEach(() => { + vi.useRealTimers(); + }); + + it('fetches 1000ms after typing in the action filter (single trailing call, page reset)', async () => { + vi.useFakeTimers(); + const wrapper = mountWithFreshStore(); + expect(adminStore.getAuditLogs).toHaveBeenCalledTimes(1); // mount fetch + wrapper.vm.activityFilterAction = 'auth.log'; + await wrapper.vm.$nextTick(); + wrapper.vm.activityFilterAction = 'auth.login'; + await wrapper.vm.$nextTick(); + vi.advanceTimersByTime(999); + expect(adminStore.getAuditLogs).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(1); + expect(adminStore.getAuditLogs).toHaveBeenCalledTimes(2); + expect(adminStore.getAuditLogs).toHaveBeenLastCalledWith( + expect.objectContaining({ action: 'auth.login', page: 1 }), + ); + }); + + it('does not fetch while the user-ID filter is not a valid ObjectId', async () => { + vi.useFakeTimers(); + const wrapper = mountWithFreshStore(); + wrapper.vm.activityFilterUserId = 'not-an-objectid'; + await wrapper.vm.$nextTick(); + vi.advanceTimersByTime(1000); + expect(adminStore.getAuditLogs).toHaveBeenCalledTimes(1); // mount fetch only + expect(wrapper.vm.activityUserIdValid).toBe(false); + wrapper.vm.activityFilterUserId = '507f1f77bcf86cd799439011'; + await wrapper.vm.$nextTick(); + vi.advanceTimersByTime(1000); + expect(adminStore.getAuditLogs).toHaveBeenCalledTimes(2); + expect(adminStore.getAuditLogs).toHaveBeenLastCalledWith( + expect.objectContaining({ userId: '507f1f77bcf86cd799439011', page: 1 }), + ); + }); + + it('Clear fetches immediately and the trailing debounce does not double-fetch', async () => { + vi.useFakeTimers(); + const wrapper = mountWithFreshStore(); + wrapper.vm.activityFilterAction = 'auth.login'; + await wrapper.vm.$nextTick(); + vi.advanceTimersByTime(1000); + expect(adminStore.getAuditLogs).toHaveBeenCalledTimes(2); + wrapper.vm.clearActivityFilters(); + expect(adminStore.getAuditLogs).toHaveBeenCalledTimes(3); + await wrapper.vm.$nextTick(); + vi.advanceTimersByTime(1000); + expect(adminStore.getAuditLogs).toHaveBeenCalledTimes(3); // debounce deduped + }); +}); + +describe('admin.activity.view — card-title chrome (datatable parity)', () => { + /** + * Read the SFC template section from disk. + * @returns {string} + */ + const readTemplate = () => { + const here = dirname(fileURLToPath(import.meta.url)); + const sfc = readFileSync(resolve(here, '../views/admin.activity.view.vue'), 'utf8'); + return sfc.split('