Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<owning-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.
Expand Down
25 changes: 18 additions & 7 deletions src/lib/helpers/surface-tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>|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<object>} Validated + permitted tabs.
* @param {(moduleName: string) => boolean} [isActive=() => true] - Optional module-activation predicate (e.g. `isModuleActive`). Defaults to keep-all.
* @returns {Array<object>} 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));
}
46 changes: 46 additions & 0 deletions src/lib/helpers/tests/surface-tabs.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
97 changes: 96 additions & 1 deletion src/modules/admin/tests/admin.activity.view.unit.tests.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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('<script>')[0];
};

it('mirrors the datatable card-title row: flex title + spacer + two compact outlined filter fields', () => {
const tmpl = readTemplate();
expect(tmpl).toMatch(/<v-card-title class="d-flex align-center ga-3">/);
expect(tmpl).toMatch(/<v-spacer><\/v-spacer>/);
expect(tmpl).toMatch(/prepend-inner-icon="fa-solid fa-magnifying-glass"/);
expect(tmpl).toMatch(/prepend-inner-icon="fa-solid fa-user"/);
expect(tmpl.match(/max-width="280"/g) || []).toHaveLength(2);
});

it('the enter-key + Search-button flow is gone (debounce replaces it)', () => {
const tmpl = readTemplate();
expect(tmpl).not.toMatch(/@keyup\.enter/);
expect(tmpl).not.toMatch(/Search\s*<\/v-btn>/);
});
});
49 changes: 40 additions & 9 deletions src/modules/admin/tests/admin.layout.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -68,6 +68,8 @@ describe('admin.layout', () => {
setActivePinia(createPinia());
adminStoreState.error = null;
adminStoreState.currentBreadcrumb = null;
adminStoreState.readiness = [];
adminStoreState.getReadiness = vi.fn();
authStoreState.serverConfig = null;
});

Expand Down Expand Up @@ -151,17 +153,10 @@ describe('admin.layout', () => {
expect(html.indexOf('Boom')).toBeLessThan(html.indexOf('page-header-tabs-stub'));
});

it('renders the mailer warning at the TOP when serverConfig.mail.configured is false', async () => {
it('does NOT render a mailer banner even when serverConfig.mail.configured is false (signal lives in the Readiness tab)', async () => {
authStoreState.serverConfig = { mail: { configured: false } };
const wrapper = mountLayout();
await wrapper.vm.$nextTick();
const html = wrapper.html();
expect(html.indexOf('No mailer configured')).toBeLessThan(html.indexOf('page-header-tabs-stub'));
});

it('does NOT render the mailer warning when mail is configured', () => {
authStoreState.serverConfig = { mail: { configured: true } };
const wrapper = mountLayout();
expect(wrapper.html()).not.toContain('No mailer configured');
});

Expand Down Expand Up @@ -211,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();
});

});
Loading
Loading