diff --git a/app/src/components/settings/panels/BillingPanel.test.tsx b/app/src/components/settings/panels/BillingPanel.test.tsx
new file mode 100644
index 000000000..6b3bdead3
--- /dev/null
+++ b/app/src/components/settings/panels/BillingPanel.test.tsx
@@ -0,0 +1,90 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const navigateBack = vi.fn();
+
+vi.mock('../hooks/useSettingsNavigation', () => ({
+ useSettingsNavigation: () => ({
+ navigateBack,
+ navigateToSettings: vi.fn(),
+ navigateToTeamManagement: vi.fn(),
+ breadcrumbs: [],
+ }),
+}));
+
+vi.mock('../components/PageBackButton', () => ({
+ default: ({ label, onClick }: { label: string; onClick: () => void }) => (
+
+ ),
+}));
+
+const openUrlMock = vi.fn();
+vi.mock('../../../utils/openUrl', () => ({ openUrl: (url: string) => openUrlMock(url) }));
+
+async function importPanel() {
+ vi.resetModules();
+ const mod = await import('./BillingPanel');
+ return mod.default;
+}
+
+describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ openUrlMock.mockResolvedValue(undefined);
+ });
+
+ it('opens the billing dashboard on mount and shows the post-open status', async () => {
+ const Panel = await importPanel();
+ render();
+
+ // The auto-open effect fires once; while the promise is pending the
+ // panel reports the "opening" status. Once it resolves we settle on
+ // the "idle" copy that tells users the browser should be open.
+ await waitFor(() => {
+ expect(openUrlMock).toHaveBeenCalledWith('https://tinyhumans.ai/dashboard');
+ });
+ await waitFor(() => {
+ expect(
+ screen.getByText('If your browser did not open, use the button above.')
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('surfaces an error message when openUrl rejects on mount', async () => {
+ openUrlMock.mockRejectedValueOnce(new Error('boom'));
+ const Panel = await importPanel();
+ render();
+
+ // First call is the auto-open attempt that rejects -> error copy renders.
+ await waitFor(() => {
+ expect(
+ screen.getByText('The browser could not be opened automatically. Use the button above.')
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('re-opens the dashboard when the user clicks the primary button', async () => {
+ const Panel = await importPanel();
+ render();
+
+ // Wait for the mount effect call to complete first so the second call
+ // is unambiguously the click.
+ await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Open billing dashboard' }));
+ await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(2));
+ expect(openUrlMock).toHaveBeenLastCalledWith('https://tinyhumans.ai/dashboard');
+ });
+
+ it('invokes the navigation back handler from both the header and the inline button', async () => {
+ const Panel = await importPanel();
+ render();
+ await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1));
+
+ fireEvent.click(screen.getByTestId('page-back-button'));
+ fireEvent.click(screen.getByRole('button', { name: 'Back to settings' }));
+ expect(navigateBack).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/app/src/components/settings/panels/MessagingPanel.test.tsx b/app/src/components/settings/panels/MessagingPanel.test.tsx
new file mode 100644
index 000000000..a1eeb5113
--- /dev/null
+++ b/app/src/components/settings/panels/MessagingPanel.test.tsx
@@ -0,0 +1,166 @@
+import { configureStore } from '@reduxjs/toolkit';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { MemoryRouter } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import channelConnectionsReducer from '../../../store/channelConnectionsSlice';
+
+const navigateBack = vi.fn();
+
+vi.mock('../hooks/useSettingsNavigation', () => ({
+ useSettingsNavigation: () => ({
+ navigateBack,
+ navigateToSettings: vi.fn(),
+ navigateToTeamManagement: vi.fn(),
+ breadcrumbs: [],
+ }),
+}));
+
+vi.mock('../components/SettingsHeader', () => ({
+ default: ({ title }: { title: string }) =>
{title}
,
+}));
+
+vi.mock('../../channels/ChannelSetupModal', () => ({
+ default: ({
+ definition,
+ onClose,
+ }: {
+ definition: { id: string; display_name: string };
+ onClose: () => void;
+ }) => (
+
+
Setup: {definition.display_name}
+
+
+ ),
+}));
+
+const useChannelDefinitionsMock = vi.fn();
+vi.mock('../../../hooks/useChannelDefinitions', () => ({
+ useChannelDefinitions: () => useChannelDefinitionsMock(),
+}));
+
+const updatePreferencesMock = vi.fn();
+vi.mock('../../../services/api/channelConnectionsApi', () => ({
+ channelConnectionsApi: { updatePreferences: (channel: string) => updatePreferencesMock(channel) },
+}));
+
+const FIXTURE_DEFINITIONS = [
+ { id: 'telegram', display_name: 'Telegram', description: 'Chat via Telegram', icon: 'telegram' },
+ { id: 'discord', display_name: 'Discord', description: 'Chat via Discord', icon: 'discord' },
+ { id: 'web', display_name: 'Web', description: 'Browser-based chat', icon: 'web' },
+];
+
+function buildStore(defaultChannel: 'telegram' | 'discord' | 'web' = 'telegram') {
+ const preloadedState = {
+ channelConnections: {
+ defaultMessagingChannel: defaultChannel,
+ connections: { telegram: {}, discord: {}, web: {} },
+ migrationCompleted: true,
+ },
+ } as unknown as Parameters[0]['preloadedState'];
+ return configureStore({
+ reducer: { channelConnections: channelConnectionsReducer },
+ preloadedState,
+ });
+}
+
+async function importPanel() {
+ vi.resetModules();
+ const mod = await import('./MessagingPanel');
+ return mod.default;
+}
+
+function renderPanel(Panel: React.ComponentType, store = buildStore()) {
+ return render(
+
+
+
+
+
+ );
+}
+
+describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useChannelDefinitionsMock.mockReturnValue({
+ definitions: FIXTURE_DEFINITIONS,
+ loading: false,
+ error: null,
+ refreshDefinitions: vi.fn(),
+ });
+ updatePreferencesMock.mockResolvedValue(undefined);
+ });
+
+ it('shows the loading state from useChannelDefinitions', async () => {
+ useChannelDefinitionsMock.mockReturnValue({
+ definitions: [],
+ loading: true,
+ error: null,
+ refreshDefinitions: vi.fn(),
+ });
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ expect(screen.getByText('Loading channel definitions...')).toBeInTheDocument();
+ });
+
+ it('surfaces the load error returned by the channel definitions hook', async () => {
+ useChannelDefinitionsMock.mockReturnValue({
+ definitions: [],
+ loading: false,
+ error: 'backend unreachable',
+ refreshDefinitions: vi.fn(),
+ });
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ expect(screen.getByText('backend unreachable')).toBeInTheDocument();
+ });
+
+ it('renders one button per definition for the default selector and excludes `web` from connections', async () => {
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ // The default selector shows ALL definitions (telegram, discord, web).
+ const defaultButtons = screen.getAllByRole('button', { name: /^Telegram$|^Discord$|^Web$/ });
+ expect(defaultButtons.map(btn => btn.textContent)).toEqual(
+ expect.arrayContaining(['Telegram', 'Discord', 'Web'])
+ );
+
+ // The "Channel Connections" cards filter out `web` per the panel's
+ // configurableChannels memo, so the configurable rows are only
+ // telegram + discord.
+ const connectionRows = screen.getAllByRole('button', { name: /Chat via (Telegram|Discord)/ });
+ expect(connectionRows).toHaveLength(2);
+ });
+
+ it('opens the ChannelSetupModal for the clicked channel and closes it', async () => {
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ const telegramRow = screen.getByRole('button', { name: /Chat via Telegram/ });
+ fireEvent.click(telegramRow);
+
+ const modal = await screen.findByTestId('channel-setup-modal');
+ expect(modal).toHaveAttribute('data-channel', 'telegram');
+
+ fireEvent.click(screen.getByRole('button', { name: 'close' }));
+ await waitFor(() => {
+ expect(screen.queryByTestId('channel-setup-modal')).not.toBeInTheDocument();
+ });
+ });
+
+ it('clicking a default-channel button calls updatePreferences for that channel', async () => {
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ // discord is not currently the default; clicking selects it.
+ fireEvent.click(screen.getByRole('button', { name: 'Discord' }));
+ await waitFor(() => expect(updatePreferencesMock).toHaveBeenCalledWith('discord'));
+ });
+});
diff --git a/app/src/components/settings/panels/NotificationRoutingPanel.test.tsx b/app/src/components/settings/panels/NotificationRoutingPanel.test.tsx
new file mode 100644
index 000000000..e91907c50
--- /dev/null
+++ b/app/src/components/settings/panels/NotificationRoutingPanel.test.tsx
@@ -0,0 +1,158 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const navigateBack = vi.fn();
+
+vi.mock('../hooks/useSettingsNavigation', () => ({
+ useSettingsNavigation: () => ({
+ navigateBack,
+ navigateToSettings: vi.fn(),
+ navigateToTeamManagement: vi.fn(),
+ breadcrumbs: [],
+ }),
+}));
+
+vi.mock('../components/SettingsHeader', () => ({
+ default: ({ title }: { title: string }) => {title}
,
+}));
+
+const fetchStatsMock = vi.fn();
+const getSettingsMock = vi.fn();
+const setSettingsMock = vi.fn();
+vi.mock('../../../services/notificationService', () => ({
+ fetchNotificationStats: () => fetchStatsMock(),
+ getNotificationSettings: (provider: string) => getSettingsMock(provider),
+ setNotificationSettings: (payload: unknown) => setSettingsMock(payload),
+}));
+
+const PROVIDERS = ['gmail', 'slack', 'discord', 'whatsapp'];
+
+async function importPanel() {
+ vi.resetModules();
+ const mod = await import('./NotificationRoutingPanel');
+ return mod.default;
+}
+
+function renderPanel(Panel: React.ComponentType) {
+ return render(
+
+
+
+ );
+}
+
+function defaultSettings() {
+ return { enabled: true, importance_threshold: 0.3, route_to_orchestrator: true };
+}
+
+describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ fetchStatsMock.mockResolvedValue({ total: 12, unread: 5, unscored: 2 });
+ getSettingsMock.mockImplementation((provider: string) =>
+ Promise.resolve({ provider, ...defaultSettings() })
+ );
+ setSettingsMock.mockResolvedValue(undefined);
+ });
+
+ it('renders the pipeline stats card once fetchNotificationStats resolves', async () => {
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ await waitFor(() => expect(screen.getByText('Pipeline stats')).toBeInTheDocument());
+ // total / unread / unscored values appear as separate stat tiles
+ expect(screen.getByText('12')).toBeInTheDocument();
+ expect(screen.getByText('5')).toBeInTheDocument();
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+
+ it('omits the pipeline stats card when fetchNotificationStats rejects', async () => {
+ fetchStatsMock.mockRejectedValueOnce(new Error('stats down'));
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ // Settings still load for each provider so the per-provider section
+ // renders, but the stats card is absent.
+ await waitFor(() => expect(getSettingsMock).toHaveBeenCalledTimes(PROVIDERS.length));
+ expect(screen.queryByText('Pipeline stats')).not.toBeInTheDocument();
+ });
+
+ it('renders one row per provider with persisted threshold values', async () => {
+ getSettingsMock.mockImplementation((provider: string) =>
+ Promise.resolve({
+ provider,
+ enabled: true,
+ importance_threshold: 0.45,
+ route_to_orchestrator: true,
+ })
+ );
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ await waitFor(() => expect(getSettingsMock).toHaveBeenCalledTimes(PROVIDERS.length));
+ // Provider headings render capitalized via CSS but the underlying text
+ // is lowercased; assert against the rendered text directly.
+ for (const provider of PROVIDERS) {
+ expect(screen.getByText(provider)).toBeInTheDocument();
+ }
+ // Each row formats the threshold to 2 decimals — four rows × 0.45.
+ expect(screen.getAllByText('0.45')).toHaveLength(PROVIDERS.length);
+ });
+
+ it('toggling enabled for a provider fires setNotificationSettings', async () => {
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ await waitFor(() => expect(getSettingsMock).toHaveBeenCalledTimes(PROVIDERS.length));
+
+ // Each provider exposes an "Enabled" checkbox; the first one is gmail's.
+ const enabledCheckboxes = screen.getAllByRole('checkbox', { name: 'Enabled' });
+ expect(enabledCheckboxes).toHaveLength(PROVIDERS.length);
+ fireEvent.click(enabledCheckboxes[0]);
+
+ await waitFor(() =>
+ expect(setSettingsMock).toHaveBeenCalledWith({
+ provider: 'gmail',
+ enabled: false,
+ importance_threshold: 0.3,
+ route_to_orchestrator: true,
+ })
+ );
+ });
+
+ it('disables the controls and surfaces a retry hint for providers that fail to load', async () => {
+ getSettingsMock.mockImplementation((provider: string) => {
+ if (provider === 'gmail') return Promise.reject(new Error('boom'));
+ return Promise.resolve({ provider, ...defaultSettings() });
+ });
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ await waitFor(() =>
+ expect(
+ screen.getByText('Failed to load settings. Reopen this panel to retry.')
+ ).toBeInTheDocument()
+ );
+
+ // The four `Enabled` checkboxes still render, but the first row (gmail)
+ // is disabled because its settings never loaded.
+ const enabledCheckboxes = screen.getAllByRole('checkbox', { name: 'Enabled' });
+ expect(enabledCheckboxes[0]).toBeDisabled();
+ // The slack row should still be interactive.
+ expect(enabledCheckboxes[1]).not.toBeDisabled();
+ });
+
+ it('renders the static "How it works" explainer rows', async () => {
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ await waitFor(() => expect(getSettingsMock).toHaveBeenCalled());
+ // The four routing tiers are part of the static explainer and should
+ // render regardless of network state.
+ expect(screen.getByText('Drop')).toBeInTheDocument();
+ expect(screen.getByText('Acknowledge')).toBeInTheDocument();
+ expect(screen.getByText('React')).toBeInTheDocument();
+ expect(screen.getByText('Escalate')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/settings/panels/NotificationsPanel.test.tsx b/app/src/components/settings/panels/NotificationsPanel.test.tsx
new file mode 100644
index 000000000..d61a70998
--- /dev/null
+++ b/app/src/components/settings/panels/NotificationsPanel.test.tsx
@@ -0,0 +1,173 @@
+import { configureStore } from '@reduxjs/toolkit';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { MemoryRouter } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import notificationReducer, { type NotificationState } from '../../../store/notificationSlice';
+
+const navigateBack = vi.fn();
+
+vi.mock('../hooks/useSettingsNavigation', () => ({
+ useSettingsNavigation: () => ({
+ navigateBack,
+ navigateToSettings: vi.fn(),
+ navigateToTeamManagement: vi.fn(),
+ breadcrumbs: [],
+ }),
+}));
+
+vi.mock('../components/SettingsHeader', () => ({
+ default: ({ title }: { title: string }) => {title}
,
+}));
+
+const getBypassPrefsMock = vi.fn();
+const setGlobalDndMock = vi.fn();
+vi.mock('../../../services/webviewAccountService', () => ({
+ getBypassPrefs: () => getBypassPrefsMock(),
+ setGlobalDnd: (enabled: boolean) => setGlobalDndMock(enabled),
+}));
+
+function buildStore(preloaded?: Partial) {
+ return configureStore({
+ reducer: { notifications: notificationReducer },
+ preloadedState: preloaded
+ ? {
+ notifications: {
+ items: [],
+ preferences: {
+ messages: true,
+ agents: true,
+ skills: true,
+ system: true,
+ meetings: true,
+ reminders: true,
+ important: true,
+ },
+ integrationItems: [],
+ integrationUnreadCount: 0,
+ integrationLoading: false,
+ integrationError: null,
+ ...preloaded,
+ },
+ }
+ : undefined,
+ });
+}
+
+async function importPanel() {
+ vi.resetModules();
+ const mod = await import('./NotificationsPanel');
+ return mod.default;
+}
+
+function renderPanel(Panel: React.ComponentType, store = buildStore()) {
+ return render(
+
+
+
+
+
+ );
+}
+
+describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getBypassPrefsMock.mockResolvedValue({ global_dnd: false });
+ setGlobalDndMock.mockResolvedValue(undefined);
+ });
+
+ it('renders one toggle per category plus the DND switch', async () => {
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ // The DND toggle starts in a loading skeleton state until getBypassPrefs
+ // resolves; the seven category toggles render synchronously.
+ await waitFor(() => {
+ expect(screen.getByLabelText('Toggle Do Not Disturb')).toBeInTheDocument();
+ });
+ expect(screen.getByLabelText('Toggle Messages notifications')).toBeInTheDocument();
+ expect(screen.getByLabelText('Toggle Agent activity notifications')).toBeInTheDocument();
+ expect(screen.getByLabelText('Toggle Skills notifications')).toBeInTheDocument();
+ expect(screen.getByLabelText('Toggle System notifications')).toBeInTheDocument();
+ expect(screen.getByLabelText('Toggle Meetings notifications')).toBeInTheDocument();
+ expect(screen.getByLabelText('Toggle Reminders notifications')).toBeInTheDocument();
+ expect(screen.getByLabelText('Toggle Important events notifications')).toBeInTheDocument();
+ });
+
+ it('reflects the persisted global_dnd value once getBypassPrefs resolves', async () => {
+ getBypassPrefsMock.mockResolvedValueOnce({ global_dnd: true });
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ await waitFor(() => {
+ const dnd = screen.getByLabelText('Toggle Do Not Disturb');
+ expect(dnd).toHaveAttribute('aria-checked', 'true');
+ });
+ });
+
+ it('toggling DND calls setGlobalDnd and updates the optimistic UI', async () => {
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ const dnd = await screen.findByLabelText('Toggle Do Not Disturb');
+ expect(dnd).toHaveAttribute('aria-checked', 'false');
+
+ fireEvent.click(dnd);
+ await waitFor(() => expect(setGlobalDndMock).toHaveBeenCalledWith(true));
+ expect(dnd).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('rolls back the DND toggle when setGlobalDnd rejects', async () => {
+ setGlobalDndMock.mockRejectedValueOnce(new Error('rpc down'));
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ const dnd = await screen.findByLabelText('Toggle Do Not Disturb');
+ fireEvent.click(dnd);
+ // After the rejection, the optimistic flip is undone.
+ await waitFor(() => expect(setGlobalDndMock).toHaveBeenCalled());
+ await waitFor(() => expect(dnd).toHaveAttribute('aria-checked', 'false'));
+ });
+
+ it('clicking a category toggle flips its aria-checked state', async () => {
+ const Panel = await importPanel();
+ renderPanel(Panel);
+
+ const messagesToggle = screen.getByLabelText('Toggle Messages notifications');
+ // Default preference is `true`; clicking should flip to `false`.
+ expect(messagesToggle).toHaveAttribute('aria-checked', 'true');
+ fireEvent.click(messagesToggle);
+ expect(messagesToggle).toHaveAttribute('aria-checked', 'false');
+ });
+
+ it('reads category state from the redux preferences slice', async () => {
+ const Panel = await importPanel();
+ const store = buildStore({
+ preferences: {
+ messages: false,
+ agents: true,
+ skills: false,
+ system: true,
+ meetings: true,
+ reminders: false,
+ important: true,
+ },
+ });
+ renderPanel(Panel, store);
+
+ expect(screen.getByLabelText('Toggle Messages notifications')).toHaveAttribute(
+ 'aria-checked',
+ 'false'
+ );
+ expect(screen.getByLabelText('Toggle Skills notifications')).toHaveAttribute(
+ 'aria-checked',
+ 'false'
+ );
+ expect(screen.getByLabelText('Toggle Agent activity notifications')).toHaveAttribute(
+ 'aria-checked',
+ 'true'
+ );
+ });
+});
diff --git a/app/src/components/settings/panels/TeamInvitesPanel.test.tsx b/app/src/components/settings/panels/TeamInvitesPanel.test.tsx
new file mode 100644
index 000000000..4fa3117c0
--- /dev/null
+++ b/app/src/components/settings/panels/TeamInvitesPanel.test.tsx
@@ -0,0 +1,149 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const navigateBack = vi.fn();
+
+vi.mock('../hooks/useSettingsNavigation', () => ({
+ useSettingsNavigation: () => ({
+ navigateBack,
+ navigateToTeamManagement: vi.fn(),
+ navigateToSettings: vi.fn(),
+ breadcrumbs: [],
+ }),
+}));
+
+vi.mock('../components/SettingsHeader', () => ({
+ default: ({ title }: { title: string }) => {title}
,
+}));
+
+const teamApiMock = { createInvite: vi.fn(), revokeInvite: vi.fn() };
+vi.mock('../../../services/api/teamApi', () => ({ teamApi: teamApiMock }));
+
+const useCoreStateMock = vi.fn();
+vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => useCoreStateMock() }));
+
+const FUTURE = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
+const PAST = new Date(Date.now() - 60 * 60 * 1000).toISOString();
+
+function makeInvite(overrides: Record = {}) {
+ return {
+ _id: 'inv-active',
+ code: 'ACTIVE-123',
+ createdBy: 'user-1',
+ expiresAt: FUTURE,
+ maxUses: 0,
+ currentUses: 0,
+ usageHistory: [],
+ ...overrides,
+ };
+}
+
+function defaultCoreState(overrides: Record = {}) {
+ const refreshTeamInvites = vi.fn().mockResolvedValue(undefined);
+ return {
+ snapshot: { currentUser: { _id: 'user-me', activeTeamId: 'team-a' } },
+ teams: [{ team: { _id: 'team-a', isPersonal: false }, role: 'ADMIN' }],
+ teamMembersById: {},
+ teamInvitesById: {
+ 'team-a': [
+ makeInvite(),
+ makeInvite({ _id: 'inv-expired', code: 'EXPIRED-1', expiresAt: PAST }),
+ makeInvite({ _id: 'inv-used', code: 'USED-1', maxUses: 1, currentUses: 1 }),
+ ],
+ },
+ refreshTeamInvites,
+ ...overrides,
+ };
+}
+
+async function importPanel() {
+ vi.resetModules();
+ const mod = await import('./TeamInvitesPanel');
+ return mod.default;
+}
+
+function renderAtRoute(Panel: React.ComponentType, path = '/settings/team/manage/team-a/invites') {
+ return render(
+
+
+ } />
+ } />
+
+
+ );
+}
+
+describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ teamApiMock.createInvite.mockResolvedValue(undefined);
+ teamApiMock.revokeInvite.mockResolvedValue(undefined);
+ useCoreStateMock.mockReturnValue(defaultCoreState());
+ Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
+ });
+
+ it('renders invite codes and tags them with their state (Expired / Used Up)', async () => {
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ expect(screen.getByText('ACTIVE-123')).toBeInTheDocument();
+ expect(screen.getByText('EXPIRED-1')).toBeInTheDocument();
+ expect(screen.getByText('USED-1')).toBeInTheDocument();
+ expect(screen.getAllByText('Expired').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('Used Up')).toBeInTheDocument();
+ });
+
+ it('Generate Invite triggers teamApi.createInvite for the current team', async () => {
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Generate Invite' }));
+ await waitFor(() => expect(teamApiMock.createInvite).toHaveBeenCalledWith('team-a'));
+ });
+
+ it('hides the Generate button and revoke buttons for non-admin viewers', async () => {
+ useCoreStateMock.mockReturnValue(
+ defaultCoreState({ teams: [{ team: { _id: 'team-a', isPersonal: false }, role: 'MEMBER' }] })
+ );
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ expect(screen.queryByRole('button', { name: 'Generate Invite' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Revoke invite' })).not.toBeInTheDocument();
+ });
+
+ it('copy button writes the invite code to the clipboard', async () => {
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ // Active invite is the first one in the list; its Copy button is the
+ // first "Copy invite code" button.
+ const copyBtns = screen.getAllByRole('button', { name: 'Copy invite code' });
+ fireEvent.click(copyBtns[0]);
+
+ await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ACTIVE-123'));
+ });
+
+ it('revoke flow opens the confirmation modal and calls revokeInvite on confirm', async () => {
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ // Only one active invite → one revoke button.
+ fireEvent.click(screen.getByRole('button', { name: 'Revoke invite' }));
+ const confirmBtn = await screen.findByRole('button', { name: 'Revoke Invite' });
+ fireEvent.click(confirmBtn);
+
+ await waitFor(() =>
+ expect(teamApiMock.revokeInvite).toHaveBeenCalledWith('team-a', 'inv-active')
+ );
+ });
+
+ it('renders the empty-state hint when no invites exist', async () => {
+ useCoreStateMock.mockReturnValue(defaultCoreState({ teamInvitesById: { 'team-a': [] } }));
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ await waitFor(() => expect(screen.getByText('No invites yet')).toBeInTheDocument());
+ });
+});
diff --git a/app/src/components/settings/panels/TeamManagementPanel.test.tsx b/app/src/components/settings/panels/TeamManagementPanel.test.tsx
new file mode 100644
index 000000000..496f1e60b
--- /dev/null
+++ b/app/src/components/settings/panels/TeamManagementPanel.test.tsx
@@ -0,0 +1,160 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const navigateBack = vi.fn();
+const navigateToSettings = vi.fn();
+
+vi.mock('../hooks/useSettingsNavigation', () => ({
+ useSettingsNavigation: () => ({
+ navigateBack,
+ navigateToSettings,
+ navigateToTeamManagement: vi.fn(),
+ breadcrumbs: [],
+ }),
+}));
+
+vi.mock('../components/SettingsHeader', () => ({
+ default: ({ title }: { title: string }) => {title}
,
+}));
+
+const teamApiMock = { updateTeam: vi.fn(), deleteTeam: vi.fn() };
+vi.mock('../../../services/api/teamApi', () => ({ teamApi: teamApiMock }));
+
+const useCoreStateMock = vi.fn();
+vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => useCoreStateMock() }));
+
+function makeTeam(overrides: Record = {}) {
+ return {
+ _id: 'team-a',
+ name: 'Acme Team',
+ slug: 'acme',
+ createdBy: 'user-1',
+ isPersonal: false,
+ maxMembers: 10,
+ subscription: { plan: 'FREE', hasActiveSubscription: false },
+ usage: { dailyTokenLimit: 0, remainingTokens: 0, activeSessionCount: 0 },
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ ...overrides,
+ };
+}
+
+function defaultCoreState(overrides: Record = {}) {
+ const refreshTeams = vi.fn().mockResolvedValue(undefined);
+ return {
+ snapshot: { currentUser: { _id: 'user-1', activeTeamId: 'team-a' } },
+ teams: [{ team: makeTeam(), role: 'ADMIN' }],
+ teamMembersById: {},
+ teamInvitesById: {},
+ refreshTeams,
+ ...overrides,
+ };
+}
+
+async function importPanel() {
+ vi.resetModules();
+ const mod = await import('./TeamManagementPanel');
+ return mod.default;
+}
+
+function renderAtRoute(Panel: React.ComponentType, teamId = 'team-a') {
+ return render(
+
+
+ } />
+
+
+ );
+}
+
+describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ teamApiMock.updateTeam.mockResolvedValue(undefined);
+ teamApiMock.deleteTeam.mockResolvedValue(undefined);
+ useCoreStateMock.mockReturnValue(defaultCoreState());
+ });
+
+ it('renders the team header and the four management entries for an admin', async () => {
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ expect(screen.getByText('Acme Team')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Members/ })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Invites/ })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Team Settings/ })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Delete Team/ })).toBeInTheDocument();
+ });
+
+ it('hides the Delete Team button for personal teams', async () => {
+ useCoreStateMock.mockReturnValue(
+ defaultCoreState({ teams: [{ team: makeTeam({ isPersonal: true }), role: 'ADMIN' }] })
+ );
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ expect(screen.queryByRole('button', { name: /Delete Team/ })).not.toBeInTheDocument();
+ });
+
+ it('renders the not-found message when the teamId is unknown', async () => {
+ useCoreStateMock.mockReturnValue(defaultCoreState({ teams: [] }));
+ const Panel = await importPanel();
+ renderAtRoute(Panel, 'team-zzz');
+
+ expect(screen.getByText('Team not found')).toBeInTheDocument();
+ });
+
+ it('redirects (navigateBack) when the viewer is not an admin', async () => {
+ useCoreStateMock.mockReturnValue(
+ defaultCoreState({ teams: [{ team: makeTeam(), role: 'MEMBER' }] })
+ );
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ await waitFor(() => expect(navigateBack).toHaveBeenCalled());
+ });
+
+ it('Members entry navigates to the members sub-route', async () => {
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ fireEvent.click(screen.getByRole('button', { name: /Members/ }));
+ expect(navigateToSettings).toHaveBeenCalledWith('team/manage/team-a/members');
+ });
+
+ it('Invites entry navigates to the invites sub-route', async () => {
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ fireEvent.click(screen.getByRole('button', { name: /Invites/ }));
+ expect(navigateToSettings).toHaveBeenCalledWith('team/manage/team-a/invites');
+ });
+
+ it('Team Settings opens the edit modal pre-filled with the current name and saves the update', async () => {
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ fireEvent.click(screen.getByRole('button', { name: /Team Settings/ }));
+ const nameInput = await screen.findByPlaceholderText('Enter team name');
+ expect(nameInput).toHaveValue('Acme Team');
+ fireEvent.change(nameInput, { target: { value: 'Renamed Team' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
+
+ await waitFor(() =>
+ expect(teamApiMock.updateTeam).toHaveBeenCalledWith('team-a', { name: 'Renamed Team' })
+ );
+ });
+
+ it('Delete Team opens the confirmation modal and calls deleteTeam on confirm', async () => {
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ fireEvent.click(screen.getByRole('button', { name: /Delete Team/ }));
+ // The modal exposes a second "Delete Team" button as the confirm action.
+ const buttons = await screen.findAllByRole('button', { name: 'Delete Team' });
+ // The last one rendered is the confirmation button inside the modal.
+ fireEvent.click(buttons[buttons.length - 1]);
+ await waitFor(() => expect(teamApiMock.deleteTeam).toHaveBeenCalledWith('team-a'));
+ });
+});
diff --git a/app/src/components/settings/panels/TeamMembersPanel.test.tsx b/app/src/components/settings/panels/TeamMembersPanel.test.tsx
new file mode 100644
index 000000000..fbaff65ca
--- /dev/null
+++ b/app/src/components/settings/panels/TeamMembersPanel.test.tsx
@@ -0,0 +1,157 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const navigateBack = vi.fn();
+
+vi.mock('../hooks/useSettingsNavigation', () => ({
+ useSettingsNavigation: () => ({
+ navigateBack,
+ navigateToTeamManagement: vi.fn(),
+ navigateToSettings: vi.fn(),
+ breadcrumbs: [],
+ }),
+}));
+
+vi.mock('../components/SettingsHeader', () => ({
+ default: ({ title }: { title: string }) => {title}
,
+}));
+
+const teamApiMock = { changeMemberRole: vi.fn(), removeMember: vi.fn() };
+vi.mock('../../../services/api/teamApi', () => ({ teamApi: teamApiMock }));
+
+const useCoreStateMock = vi.fn();
+vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => useCoreStateMock() }));
+
+function makeMember(id: string, overrides: Record = {}) {
+ return {
+ _id: `mem-${id}`,
+ user: {
+ _id: `user-${id}`,
+ firstName: `First${id}`,
+ lastName: `Last${id}`,
+ username: `user${id}`,
+ },
+ role: 'MEMBER',
+ joinedAt: '2026-01-01T00:00:00Z',
+ ...overrides,
+ };
+}
+
+function defaultCoreState(overrides: Record = {}) {
+ const refreshTeamMembers = vi.fn().mockResolvedValue(undefined);
+ return {
+ snapshot: { currentUser: { _id: 'user-me', activeTeamId: 'team-a' } },
+ teams: [{ team: { _id: 'team-a', isPersonal: false }, role: 'ADMIN' }],
+ teamMembersById: {
+ 'team-a': [
+ makeMember('me', {
+ _id: 'mem-me',
+ user: { _id: 'user-me', firstName: 'Me', lastName: 'Self', username: 'me' },
+ role: 'ADMIN',
+ }),
+ makeMember('1'),
+ ],
+ },
+ teamInvitesById: {},
+ refreshTeamMembers,
+ ...overrides,
+ };
+}
+
+async function importPanel() {
+ vi.resetModules();
+ const mod = await import('./TeamMembersPanel');
+ return mod.default;
+}
+
+function renderAtRoute(Panel: React.ComponentType, path = '/settings/team/manage/team-a/members') {
+ return render(
+
+
+ } />
+ } />
+
+
+ );
+}
+
+describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ teamApiMock.changeMemberRole.mockResolvedValue(undefined);
+ teamApiMock.removeMember.mockResolvedValue(undefined);
+ useCoreStateMock.mockReturnValue(defaultCoreState());
+ });
+
+ it('renders members and tags the current user with "(You)"', async () => {
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ expect(screen.getByText('Me Self')).toBeInTheDocument();
+ expect(screen.getByText('First1 Last1')).toBeInTheDocument();
+ expect(screen.getByText('(You)')).toBeInTheDocument();
+ // Two members rendered → count line shows "2 members".
+ expect(screen.getByText('2 members')).toBeInTheDocument();
+ });
+
+ it('uses activeTeamId when not in the team-management route context', async () => {
+ const refreshTeamMembers = vi.fn().mockResolvedValue(undefined);
+ useCoreStateMock.mockReturnValue(defaultCoreState({ refreshTeamMembers }));
+ const Panel = await importPanel();
+ renderAtRoute(Panel, '/settings/team/members');
+
+ await waitFor(() => expect(refreshTeamMembers).toHaveBeenCalledWith('team-a'));
+ });
+
+ it('admin sees a role-change dropdown and a remove button for other members', async () => {
+ const Panel = await importPanel();
+ renderAtRoute(Panel);
+
+ // Other member surfaces a