diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 9e13f112a..139f66c4b 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -4,6 +4,25 @@ Breaking changes and upgrade notes for downstream projects. --- +## surface-tabs: activation-aware tab filtering (2026-06-12, #4295) + +Module-contributed surface tabs now respect `config.modules.{name}.activated`. Previously a downstream setting `modules: { invitations: { activated: false } }` still rendered the admin "Invitations" tab + the account "Referrals" tab (the module fragment's `admin.tabs` / `users.extraTabs` merge unconditionally at generation time) while the routes were correctly NOT injected — clicking the tab landed on a 404. + +### What changed (this repo) + +- `resolveSurfaceTabs(tabs, can, isActive = () => true)` gained an optional third parameter: tab descriptors may carry an optional `module` field, and a tab whose `module` is reported inactive is filtered out. Tabs without a `module` field are never activation-filtered; omitting `isActive` keeps every tab (existing callers unchanged). +- `core.surfaceTabBar.component.vue` passes `isModuleActive` as the third argument — its single call site means ALL surfaces using `CoreSurfaceTabBar` (admin, account, org-settings) get the gate. +- The invitations config fragment tags both of its tab descriptors with `module: 'invitations'`; the regenerated `src/config/index.js` carries the field. +- The filter is render-time on purpose (single source of truth: `isModuleActive`). Generation-time pruning would have to run after all 5 merge layers, handle env-var string coercion (`'false' !== false`), and silently diverge the committed generated file. + +### Action required for downstream projects (`/update-stack`) + +1. All files are devkit-owned → arrive via `/update-stack`. Re-run `npm run generateConfig` (any `dev`/`build`/`preview` does it) so the regenerated config picks up the `module` fields. +2. **⚠️ If a downstream overrides `admin.tabs` or `users.extraTabs` in its `{project}.config.js`** (deepMerge REPLACES arrays — the override is the whole array), add `module: ''` to any module-owned tab descriptors in the override, or those tabs will keep rendering when that module is deactivated. Tabs without a `module` field are always shown — project-owned tabs need no change. +3. Deactivating a module (e.g. `modules: { invitations: { activated: false } }`) now hides its surface tabs as well as its routes — no more tab-to-404. + +--- + ## organizations: org email-invite UI removed (2026-06-11, #4280) Phase 7 of the invitations↔org decouple epic. Pairs with Node P4 (#3812), which deleted the backends (`POST /api/organizations/:id/invites`, `GET /api/invites/:token`, `POST /api/invites/:token/accept`) — this UI was dead. 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']); + }); +}); 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(' 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'); - }); -}); diff --git a/src/modules/core/components/core.surfaceTabBar.component.vue b/src/modules/core/components/core.surfaceTabBar.component.vue index 7c86a8f4f..e896ff517 100644 --- a/src/modules/core/components/core.surfaceTabBar.component.vue +++ b/src/modules/core/components/core.surfaceTabBar.component.vue @@ -1,18 +1,22 @@