diff --git a/src/components/doormile/AgentOperationsBanner.jsx b/src/components/doormile/AgentOperationsBanner.jsx
index e30c2c1..84d3da8 100644
--- a/src/components/doormile/AgentOperationsBanner.jsx
+++ b/src/components/doormile/AgentOperationsBanner.jsx
@@ -10,6 +10,8 @@ import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
import { wallClockNow } from '@/lib/assistant/agent/signals';
import { executeProposal, canExecuteProposal } from '@/lib/assistant/agent/actions';
import { Button } from '@/components/ui/button';
+import { useAuth } from '@/lib/AuthContext';
+import { canOnboardClients } from '@/lib/clientOnboarding';
/**
* AgentOperationsBanner
@@ -40,6 +42,10 @@ export default function AgentOperationsBanner({
className = ''
}) {
const navigate = useNavigate();
+ // The Skills & Tools tab is the owner login's only; nobody else gets a
+ // button into it.
+ const { user, isClient } = useAuth();
+ const canConfigure = canOnboardClients(user, isClient);
// Which settings the rules are running on — the registry's, or the code
// defaults while it is unreachable. Said on screen, so a tuned threshold is
// never assumed when it is not in force.
@@ -141,7 +147,7 @@ export default function AgentOperationsBanner({
-
navigate('/doormile/settings?tab=skills')}
@@ -150,7 +156,7 @@ export default function AgentOperationsBanner({
>
Configure skills
-
+ }
!tab.ownerOnly || isOwner);
+}
+
export default function Settings() {
+ const { user, isClient } = useAuth();
+ const TABS = settingsTabsFor(canOnboardClients(user, isClient));
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
@@ -42,6 +55,8 @@ export default function Settings() {
if (tabParam && TABS.some((t) => t.id === tabParam)) {
setActiveTab(tabParam);
}
+ // TABS is derived from the login, which does not change on this page.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabParam]);
const handleTabChange = (id) => {
@@ -121,7 +136,7 @@ export default function Settings() {
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
>
- {activeTab === 'skills' && }
+ {activeTab === 'skills' && TABS.some((t) => t.id === 'skills') && }
{activeTab === 'profitability' && (
);
jest.mock('@/lib/assistant/scan');
jest.mock('@/lib/assistant/agent/AgentFactory');
+// The "Configure skills" button is the owner login's only.
+let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin' }, isClient: false };
+jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth }));
const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -112,6 +115,26 @@ describe('AgentOperationsBanner', () => {
expect(screen.getByTestId('where')).toHaveTextContent('/doormile/settings?tab=skills');
});
+ it('offers "Configure skills" to nobody but the owner login', async () => {
+ scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
+ AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [] });
+ const owner = mockAuth;
+ try {
+ for (const who of [
+ { user: { email: 'ops@doormile.com', role: 'admin' }, isClient: false },
+ { user: { email: 'ops@client.test', role: 'manager', tenantid: 4 }, isClient: true },
+ ]) {
+ mockAuth = who;
+ const { unmount } = renderBanner();
+ await screen.findAllByText(/./);
+ expect(screen.queryByRole('button', { name: /Configure skills/i })).toBeNull();
+ unmount();
+ }
+ } finally {
+ mockAuth = owner;
+ }
+ });
+
it('says when it is running on default thresholds, and stops once the registry answers', async () => {
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [] });
diff --git a/tests/lib/agentClickThrough.test.jsx b/tests/lib/agentClickThrough.test.jsx
index 3cf0749..3e78918 100644
--- a/tests/lib/agentClickThrough.test.jsx
+++ b/tests/lib/agentClickThrough.test.jsx
@@ -36,6 +36,8 @@ jest.mock('@/api/doormile', () => {
});
jest.mock('@/api/doormile/notify', () => ({ OpenToast: jest.fn() }));
+const mockAuth = { user: { email: 'admin@doormile.com', role: 'admin' }, isClient: false };
+jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth }));
const api = require('@/api/doormile');
const { OpenToast } = require('@/api/doormile/notify');
diff --git a/tests/lib/settingsTabs.test.js b/tests/lib/settingsTabs.test.js
new file mode 100644
index 0000000..25a14c6
--- /dev/null
+++ b/tests/lib/settingsTabs.test.js
@@ -0,0 +1,23 @@
+jest.mock('lucide-react', () => new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : () => null) }));
+jest.mock('@/pages/doormile/customers/Customers', () => () => null);
+jest.mock('@/pages/doormile/reports/Profitability', () => () => null);
+jest.mock('@/pages/doormile/settings/agentStudio/AgentStudio', () => () => null);
+jest.mock('@/lib/AuthContext', () => ({ useAuth: () => ({}) }));
+
+import { settingsTabsFor } from '@/pages/doormile/settings/Settings';
+import { canOnboardClients } from '@/lib/clientOnboarding';
+
+const ids = (isOwner) => settingsTabsFor(isOwner).map((t) => t.id);
+
+describe('Settings tabs per login', () => {
+ test('admin@doormile.com sees Skills & Tools', () => {
+ expect(ids(canOnboardClients({ email: 'admin@doormile.com', role: 'admin' }, false))).toEqual([
+ 'profitability', 'customers', 'skills',
+ ]);
+ });
+
+ test('other Doormile staff and clients do not', () => {
+ expect(ids(canOnboardClients({ email: 'ops@doormile.com', role: 'admin' }, false))).toEqual(['profitability', 'customers']);
+ expect(ids(canOnboardClients({ email: 'admin@doormile.com', role: 'admin' }, true))).toEqual(['profitability', 'customers']);
+ });
+});