diff --git a/backend/src/control_center/api/routes_auth_proxy.py b/backend/src/control_center/api/routes_auth_proxy.py index ee16a04..678697c 100644 --- a/backend/src/control_center/api/routes_auth_proxy.py +++ b/backend/src/control_center/api/routes_auth_proxy.py @@ -93,6 +93,19 @@ async def auth_refresh_proxy(request: Request) -> JSONResponse: return await _proxy_to_auth("/auth/refresh", request) +# Mode B Phase 2: Workspace Switcher. Same _proxy_to_auth helper as every +# other route in this file, no new logic -- the request body (team_id, +# optionally refresh_token) and the omnibioai_session cookie both pass +# through unmodified, and auth-service's own response (access_token/ +# refresh_token JSON body + its Set-Cookie) is relayed back exactly like +# /auth/login and /auth/refresh already are. This service never inspects +# or trusts team_id itself -- it's an opaque relay, same posture as +# every other route here. +@router.post("/auth/switch-team") +async def auth_switch_team_proxy(request: Request) -> JSONResponse: + return await _proxy_to_auth("/auth/switch-team", request) + + @router.post("/auth/logout") async def auth_logout_proxy(request: Request) -> JSONResponse: # SSO Phase 2 PR13: auth-service's LogoutRequest.refresh_token is still diff --git a/backend/tests/test_routes_auth_proxy.py b/backend/tests/test_routes_auth_proxy.py index c41ef26..b4a0d92 100644 --- a/backend/tests/test_routes_auth_proxy.py +++ b/backend/tests/test_routes_auth_proxy.py @@ -194,6 +194,78 @@ def test_auth_service_unreachable_returns_503(self) -> None: self.assertEqual(resp.status_code, 503) +class TestSwitchTeamProxy(unittest.TestCase): + """Mode B Phase 2: /auth/switch-team -- same _proxy_to_auth relay + every other route in this file already uses, no new logic. This + service never inspects team_id itself; these tests only prove the + relay (path/body/status/cookie), matching TestAuthRefreshProxy's own + shape exactly.""" + + def test_request_reaches_auth_service_at_correct_path(self) -> None: + upstream = _mock_response(200, {"access_token": "new-tok", "refresh_token": "new-rtok"}) + mock_ctx = _mock_async_client(upstream) + with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=mock_ctx): + client.post("/auth/switch-team", json={"team_id": 7}) + call_args = mock_ctx.__aenter__.return_value.post.call_args + self.assertEqual(call_args.args[0], "http://auth-service:8001/auth/switch-team") + + def test_successful_response_returned(self) -> None: + upstream = _mock_response(200, {"access_token": "new-tok", "refresh_token": "new-rtok"}) + with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=_mock_async_client(upstream)): + resp = client.post("/auth/switch-team", json={"team_id": 7}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["access_token"], "new-tok") + + def test_null_team_id_forwarded_unchanged_for_personal_workspace(self) -> None: + """team_id: null is a real, meaningful request (switch back to + the personal workspace), not an omitted/optional field -- must + reach auth-service exactly as sent, not stripped or defaulted.""" + upstream = _mock_response(200, {"access_token": "new-tok", "refresh_token": "new-rtok"}) + mock_ctx = _mock_async_client(upstream) + with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=mock_ctx): + client.post("/auth/switch-team", json={"team_id": None}) + call_kwargs = mock_ctx.__aenter__.return_value.post.call_args.kwargs + self.assertIn(b'"team_id":null', call_kwargs["content"].replace(b" ", b"")) + + def test_denied_response_propagated(self) -> None: + upstream = _mock_response(403, {"detail": "Not a member of this team"}) + with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=_mock_async_client(upstream)): + resp = client.post("/auth/switch-team", json={"team_id": 999}) + self.assertEqual(resp.status_code, 403) + self.assertEqual(resp.json()["detail"], "Not a member of this team") + + def test_not_found_response_propagated(self) -> None: + upstream = _mock_response(404, {"detail": "Team not found"}) + with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=_mock_async_client(upstream)): + resp = client.post("/auth/switch-team", json={"team_id": 999}) + self.assertEqual(resp.status_code, 404) + + def test_auth_service_unreachable_returns_503(self) -> None: + with patch( + "control_center.api.routes_auth_proxy.httpx.AsyncClient", + return_value=_mock_async_client(side_effect=httpx.ConnectError("refused")), + ): + resp = client.post("/auth/switch-team", json={"team_id": 7}) + self.assertEqual(resp.status_code, 503) + + def test_forwards_set_cookie_to_browser(self) -> None: + upstream = _mock_response( + 200, {"access_token": "new-tok", "refresh_token": "new-rtok"}, + set_cookies=["omnibioai_session=new-rtok; HttpOnly; Path=/; Domain=.omnibioai.org"], + ) + with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=_mock_async_client(upstream)): + resp = client.post("/auth/switch-team", json={"team_id": 7}) + self.assertIn("omnibioai_session=new-rtok", resp.headers.get("set-cookie", "")) + + def test_forwards_incoming_session_cookie_upstream(self) -> None: + upstream = _mock_response(200, {"access_token": "tok"}) + mock_ctx = _mock_async_client(upstream) + with patch("control_center.api.routes_auth_proxy.httpx.AsyncClient", return_value=mock_ctx): + client.post("/auth/switch-team", json={"team_id": 7}, cookies={"omnibioai_session": "cookie-rtok"}) + call_kwargs = mock_ctx.__aenter__.return_value.post.call_args.kwargs + self.assertEqual(call_kwargs["headers"]["Cookie"], "omnibioai_session=cookie-rtok") + + class TestSessionCookieForwarding(unittest.TestCase): """SSO Phase 2 PR13: _proxy_to_auth relays the omnibioai_session cookie in both directions -- previously it silently dropped Set-Cookie from diff --git a/frontend/cc-ui/src/apps/AdminApp.test.tsx b/frontend/cc-ui/src/apps/AdminApp.test.tsx index 632bed4..3e6998a 100644 --- a/frontend/cc-ui/src/apps/AdminApp.test.tsx +++ b/frontend/cc-ui/src/apps/AdminApp.test.tsx @@ -111,15 +111,15 @@ vi.mock('../pages/billing/BillingPage', () => ({ const admin: SessionUser = { userId: '1', email: 'admin@omnibioai.org', roles: ['admin'], - permissions: ['manage_config'], orgId: null, orgRoles: [], schemaVersion: 2, + permissions: ['manage_config'], orgId: null, orgRoles: [], teamId: null, teamRole: null, schemaVersion: 2, } const nonAdmin: SessionUser = { userId: '2', email: 'no-perms@omnibioai.org', roles: ['user'], - permissions: [], orgId: null, orgRoles: [], schemaVersion: 2, + permissions: [], orgId: null, orgRoles: [], teamId: null, teamRole: null, schemaVersion: 2, } const orgOnlyUser: SessionUser = { userId: '3', email: 'org-admin@acme.test', roles: ['user'], - permissions: ['manage_org'], orgId: '9', orgRoles: ['org_admin'], schemaVersion: 2, + permissions: ['manage_org'], orgId: '9', orgRoles: ['org_admin'], teamId: null, teamRole: null, schemaVersion: 2, } /** Clicks a SidebarNav item by its visible label -- the click lands on diff --git a/frontend/cc-ui/src/apps/ControlApp.test.tsx b/frontend/cc-ui/src/apps/ControlApp.test.tsx index e056e94..d830b5a 100644 --- a/frontend/cc-ui/src/apps/ControlApp.test.tsx +++ b/frontend/cc-ui/src/apps/ControlApp.test.tsx @@ -33,15 +33,15 @@ vi.mock('../pages/CloudPage', () => ({ default: () =>
{ + const newToken = makeToken({ sub: '1', exp: nowSeconds() + 900 }) + vi.stubGlobal('fetch', mockFetchByUrl({ + '/auth/switch-team': jsonResponse({ access_token: newToken, refresh_token: 'r' }), + '/auth/validate': jsonResponse({ ...validUser, team_id: null, team_role: null }), + })) + + await auth.switchTeam(null) + + const fetchMock = vi.mocked(fetch) + expect(fetchMock).toHaveBeenCalledWith( + '/auth/switch-team', + expect.objectContaining({ body: JSON.stringify({ team_id: null }) }), + ) + }) + + it('on success, persists the new access token and rehydrates teamId/teamRole', async () => { + const newToken = makeToken({ sub: '1', exp: nowSeconds() + 900 }) + vi.stubGlobal('fetch', mockFetchByUrl({ + '/auth/switch-team': jsonResponse({ access_token: newToken, refresh_token: 'r' }), + '/auth/validate': jsonResponse({ ...validUser, team_id: '7', team_role: 'admin' }), + })) + + const user = await auth.switchTeam(7) + + expect(localStorage.getItem('omnibioai_access_token')).toBe(newToken) + expect(user.teamId).toBe('7') + expect(user.teamRole).toBe('admin') + expect(auth.getSessionUser()?.teamId).toBe('7') + }) + + it('Team A -> Team B: teamId updates to the newly selected team', async () => { + const tokenB = makeToken({ sub: '1', exp: nowSeconds() + 900 }) + vi.stubGlobal('fetch', mockFetchByUrl({ + '/auth/switch-team': jsonResponse({ access_token: tokenB, refresh_token: 'r2' }), + '/auth/validate': jsonResponse({ ...validUser, team_id: '2', team_role: 'member' }), + })) + + const user = await auth.switchTeam(2) + expect(user.teamId).toBe('2') + }) + + it('Team -> Personal: teamId becomes null', async () => { + const newToken = makeToken({ sub: '1', exp: nowSeconds() + 900 }) + vi.stubGlobal('fetch', mockFetchByUrl({ + '/auth/switch-team': jsonResponse({ access_token: newToken, refresh_token: 'r' }), + '/auth/validate': jsonResponse({ ...validUser, team_id: null, team_role: null }), + })) + + const user = await auth.switchTeam(null) + expect(user.teamId).toBeNull() + expect(user.teamRole).toBeNull() + }) + + it('Personal -> Team: teamId becomes the selected team', async () => { + const newToken = makeToken({ sub: '1', exp: nowSeconds() + 900 }) + vi.stubGlobal('fetch', mockFetchByUrl({ + '/auth/switch-team': jsonResponse({ access_token: newToken, refresh_token: 'r' }), + '/auth/validate': jsonResponse({ ...validUser, team_id: '5', team_role: 'viewer' }), + })) + + const user = await auth.switchTeam(5) + expect(user.teamId).toBe('5') + }) + + it('401: reports unauthorized and throws a session-expired message, without leaking which team/org', async () => { + localStorage.setItem('omnibioai_access_token', 'stale-token') + vi.stubGlobal('fetch', vi.fn(async () => ({ + status: 401, ok: false, json: async () => ({ detail: 'Invalid refresh token' }), + } as unknown as Response))) + + let thrown: Error | null = null + try { + await auth.switchTeam(7) + } catch (e) { + thrown = e as Error + } + expect(thrown?.message).toMatch(/session has expired/i) + // reportUnauthorized() already clears the token -- same forced-logout + // path every other 401 in this app goes through. + expect(localStorage.getItem('omnibioai_access_token')).toBeNull() + }) + + it('403: throws a generic denial message that does not name the org/team boundary', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + status: 403, ok: false, json: async () => ({ detail: 'Not a member of this team' }), + } as unknown as Response))) + + let thrown: Error | null = null + try { + await auth.switchTeam(7) + } catch (e) { + thrown = e as Error + } + expect(thrown?.message).not.toMatch(/not a member/i) + expect(thrown?.message).not.toMatch(/organization/i) + expect(thrown?.message).toBeTruthy() + }) + + it('404: throws the same generic denial message as 403 -- does not reveal existence', async () => { + let thrown403: Error | null = null + vi.stubGlobal('fetch', vi.fn(async () => ({ status: 403, ok: false, json: async () => ({}) } as unknown as Response))) + try { await auth.switchTeam(7) } catch (e) { thrown403 = e as Error } + + let thrown404: Error | null = null + vi.stubGlobal('fetch', vi.fn(async () => ({ status: 404, ok: false, json: async () => ({ detail: 'Team not found' }) } as unknown as Response))) + try { await auth.switchTeam(7) } catch (e) { thrown404 = e as Error } + + expect(thrown404?.message).toBe(thrown403?.message) + }) + + it('network failure: throws a generic connectivity message, does not clear the session', async () => { + localStorage.setItem('omnibioai_access_token', 'still-valid-token') + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch') })) + + let thrown: Error | null = null + try { + await auth.switchTeam(7) + } catch (e) { + thrown = e as Error + } + expect(thrown).not.toBeNull() + // Unlike a 401, a network blip is not a session problem -- the + // previously valid token must survive it (same fail-open posture + // silent refresh's own network-failure path already has). + expect(localStorage.getItem('omnibioai_access_token')).toBe('still-valid-token') + }) + + it('successful switch followed by a validate failure throws rather than silently succeeding', async () => { + const newToken = makeToken({ sub: '1', exp: nowSeconds() + 900 }) + let call = 0 + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + call += 1 + if (url === '/auth/switch-team') { + return jsonResponse({ access_token: newToken, refresh_token: 'r' }) as unknown as Response + } + // /auth/validate network failure right after a successful switch. + throw new TypeError('Failed to fetch') + })) + + let thrown: Error | null = null + try { + await auth.switchTeam(7) + } catch (e) { + thrown = e as Error + } + expect(thrown).not.toBeNull() + expect(call).toBeGreaterThanOrEqual(2) + // The new token is still persisted (switch itself did succeed) even + // though this call couldn't confirm it -- not rolled back, matching + // validateSession's own existing fail-open behavior for a network + // blip. + expect(localStorage.getItem('omnibioai_access_token')).toBe(newToken) + }) +}) + +describe('logout clears team state', () => { + it('getSessionUser().teamId is gone after logout (cachedUser fully cleared, not merged)', async () => { + const accessToken = makeToken({ sub: '1', exp: nowSeconds() + 900 }) + vi.stubGlobal('fetch', mockFetchByUrl({ + '/auth/login': jsonResponse({ access_token: accessToken, refresh_token: 'refresh-abc' }), + '/auth/validate': jsonResponse({ ...validUser, team_id: '7', team_role: 'admin' }), + })) + await auth.login('admin@omnibioai.org', 'password') + expect(auth.getSessionUser()?.teamId).toBe('7') + + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ message: 'Logged out' }) as unknown as Response)) + await auth.logout() + + expect(auth.getSessionUser()).toBeNull() + }) +}) + describe('clearToken', () => { it('clears cached session state (getSessionUser)', async () => { const accessToken = makeToken({ sub: '1', exp: nowSeconds() + 900 }) diff --git a/frontend/cc-ui/src/auth.ts b/frontend/cc-ui/src/auth.ts index 0a3b0f2..dfd3a13 100644 --- a/frontend/cc-ui/src/auth.ts +++ b/frontend/cc-ui/src/auth.ts @@ -149,6 +149,12 @@ export async function logout(): Promise { // those fields at all. /auth/validate degrades v1 tokens to schemaVersion=1 // with orgId=null/orgRoles=[] rather than erroring, so this client mirrors // that: every field below is optional/defaulted, never assumed present. +// Mode B Phase 2: team_id/team_role, additive -- same "optional, never +// assumed present" posture as orgId/orgRoles above. team_id: null is the +// personal workspace, a valid state (not a missing one, not a fake +// sentinel like "personal") -- preserved as literal null end to end, +// exactly how /auth/validate's own response and the JWT claim itself +// already represent it. export interface SessionUser { userId: string email: string @@ -156,6 +162,8 @@ export interface SessionUser { permissions: string[] orgId: string | null orgRoles: string[] + teamId: string | null + teamRole: string | null schemaVersion: number } @@ -263,6 +271,8 @@ async function validateSession(token: string): Promise { permissions: data.permissions ?? [], orgId: data.org_id ?? null, orgRoles: data.org_role ?? [], + teamId: data.team_id ?? null, + teamRole: data.team_role ?? null, schemaVersion: data.schema_version ?? 1, } // SSO Phase 2 PR5: (re)schedule silent refresh here rather than only @@ -306,3 +316,70 @@ export async function login(email: string, password: string): Promise { + const DENIED_MESSAGE = 'Unable to switch to that team. Please try again or contact your administrator.' + + let r: Response + try { + r = await fetch('/auth/switch-team', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ team_id: teamId }), + }) + } catch { + throw new Error('Unable to switch teams right now. Check your connection and try again.') + } + + if (r.status === 401) { + // Same "drop back to login" path every other gated request's 401 + // already triggers -- a stale/expired/revoked access token is a + // session problem, not a team-switch problem, so it gets its own, + // more specific (and safe -- it's about the caller's own session, + // not a cross-team boundary) message. + reportUnauthorized() + throw new Error('Your session has expired. Please sign in again.') + } + if (!r.ok) { + throw new Error(DENIED_MESSAGE) + } + + const body = await r.json().catch(() => ({})) + if (!body.access_token) { + throw new Error(DENIED_MESSAGE) + } + + persistAccessToken(body.access_token) + const user = await validateSession(body.access_token) + if (!user) { + // The switch itself succeeded -- a new, correctly-scoped access + // token is already persisted in localStorage -- but confirming the + // new identity via /auth/validate failed (validateSession's own + // fail-open network-error path returns null without clearing the + // token). Surfaced as a failure so the caller never treats this as + // a clean success and reloads on a false premise; the persisted + // token remains valid and a retry (or the app's next natural + // mount/refresh) will pick it up correctly. + throw new Error('Switched, but could not confirm your new session. Please try again.') + } + return user +} diff --git a/frontend/cc-ui/src/components/AccessDenied.test.tsx b/frontend/cc-ui/src/components/AccessDenied.test.tsx index 963a8e9..5b2140c 100644 --- a/frontend/cc-ui/src/components/AccessDenied.test.tsx +++ b/frontend/cc-ui/src/components/AccessDenied.test.tsx @@ -6,7 +6,7 @@ import type { SessionUser } from '../auth' const user: SessionUser = { userId: '2', email: 'no-perms@omnibioai.org', roles: ['user'], - permissions: [], orgId: null, orgRoles: [], schemaVersion: 2, + permissions: [], orgId: null, orgRoles: [], teamId: null, teamRole: null, schemaVersion: 2, } describe('AccessDenied', () => { diff --git a/frontend/cc-ui/src/components/LoginScreen.test.tsx b/frontend/cc-ui/src/components/LoginScreen.test.tsx index b75322d..6b69a31 100644 --- a/frontend/cc-ui/src/components/LoginScreen.test.tsx +++ b/frontend/cc-ui/src/components/LoginScreen.test.tsx @@ -26,7 +26,7 @@ describe('LoginScreen', () => { const user = userEvent.setup() const sessionUser: auth.SessionUser = { userId: '1', email: 'admin@omnibioai.org', roles: ['admin'], - permissions: ['manage_config'], orgId: null, orgRoles: [], schemaVersion: 2, + permissions: ['manage_config'], orgId: null, orgRoles: [], teamId: null, teamRole: null, schemaVersion: 2, } vi.mocked(auth.login).mockResolvedValue(sessionUser) const onSuccess = vi.fn() diff --git a/frontend/cc-ui/src/components/shell/TeamSwitcher.test.tsx b/frontend/cc-ui/src/components/shell/TeamSwitcher.test.tsx new file mode 100644 index 0000000..015924e --- /dev/null +++ b/frontend/cc-ui/src/components/shell/TeamSwitcher.test.tsx @@ -0,0 +1,201 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import TeamSwitcher from './TeamSwitcher' +import * as auth from '../../auth' +import type { SessionUser } from '../../auth' +import * as teamsModule from '../../teams' +import type { Team } from '../../teams' + +vi.mock('../../auth', async () => { + const actual = await vi.importActual('../../auth') + return { ...actual, switchTeam: vi.fn() } +}) + +vi.mock('../../teams', async () => { + const actual = await vi.importActual('../../teams') + return { ...actual, listTeams: vi.fn() } +}) + +function makeUser(overrides: Partial = {}): SessionUser { + return { + userId: '3', email: 'user@omnibioai.test', roles: [], permissions: [], + orgId: '42', orgRoles: [], teamId: null, teamRole: null, schemaVersion: 2, + ...overrides, + } +} + +const teams: Team[] = [ + { id: 1, organization_id: 42, name: 'Genomics', member_user_ids: [3, 4], description: null, created_by_user_id: null }, + { id: 2, organization_id: 42, name: 'Proteomics', member_user_ids: [3, 5], description: null, created_by_user_id: null }, + { id: 3, organization_id: 42, name: 'Not My Team', member_user_ids: [5, 6], description: null, created_by_user_id: null }, +] + +describe('TeamSwitcher', () => { + let reloadSpy: ReturnType + + beforeEach(() => { + vi.mocked(teamsModule.listTeams).mockReset() + vi.mocked(auth.switchTeam).mockReset() + vi.mocked(teamsModule.listTeams).mockResolvedValue(teams) + reloadSpy = vi.fn() + vi.stubGlobal('location', { ...window.location, reload: reloadSpy }) + }) + + it('renders nothing without a session', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing without an organization context', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('displays "Personal Workspace" when teamId is null', async () => { + render() + expect(await screen.findByText('Personal Workspace')).toBeInTheDocument() + }) + + it('displays the active team name when teamId is set', async () => { + render() + expect(await screen.findByText('Genomics')).toBeInTheDocument() + }) + + it('lists only teams the current user is actually a member of', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + + expect(await screen.findByText('Genomics')).toBeInTheDocument() + expect(screen.getByText('Proteomics')).toBeInTheDocument() + expect(screen.queryByText('Not My Team')).not.toBeInTheDocument() + }) + + it('marks the active team distinctly from other options', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + + const genomics = await screen.findByRole('menuitemradio', { name: /Genomics/ }) + const proteomics = screen.getByRole('menuitemradio', { name: /Proteomics/ }) + expect(genomics).toHaveAttribute('aria-checked', 'true') + expect(proteomics).toHaveAttribute('aria-checked', 'false') + }) + + it('selecting another team calls switchTeam with that team id, never a client-controlled header', async () => { + const user = userEvent.setup() + vi.mocked(auth.switchTeam).mockResolvedValue(makeUser({ teamId: '2' })) + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + await user.click(await screen.findByRole('menuitemradio', { name: /Proteomics/ })) + + expect(auth.switchTeam).toHaveBeenCalledWith(2) + expect(auth.switchTeam).toHaveBeenCalledTimes(1) + }) + + it('successful switch triggers a page reload (rehydrates every team-scoped view from the new JWT)', async () => { + const user = userEvent.setup() + vi.mocked(auth.switchTeam).mockResolvedValue(makeUser({ teamId: '2' })) + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + await user.click(await screen.findByRole('menuitemradio', { name: /Proteomics/ })) + + await waitFor(() => expect(reloadSpy).toHaveBeenCalledTimes(1)) + }) + + it('Team A -> Team B: selecting a different team switches to it', async () => { + const user = userEvent.setup() + vi.mocked(auth.switchTeam).mockResolvedValue(makeUser({ teamId: '2' })) + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + await user.click(await screen.findByRole('menuitemradio', { name: /Proteomics/ })) + expect(auth.switchTeam).toHaveBeenCalledWith(2) + }) + + it('Team -> Personal: selecting Personal Workspace switches with team_id null', async () => { + const user = userEvent.setup() + vi.mocked(auth.switchTeam).mockResolvedValue(makeUser({ teamId: null })) + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + await user.click(await screen.findByRole('menuitemradio', { name: 'Personal Workspace' })) + expect(auth.switchTeam).toHaveBeenCalledWith(null) + }) + + it('Personal -> Team: selecting a team switches from the personal workspace', async () => { + const user = userEvent.setup() + vi.mocked(auth.switchTeam).mockResolvedValue(makeUser({ teamId: '1' })) + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + await user.click(await screen.findByRole('menuitemradio', { name: /Genomics/ })) + expect(auth.switchTeam).toHaveBeenCalledWith(1) + }) + + it('clicking the already-active team is a no-op (no switchTeam call)', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + await user.click(await screen.findByRole('menuitemradio', { name: /Genomics/ })) + expect(auth.switchTeam).not.toHaveBeenCalled() + }) + + it('failed switch shows an error, does not reload, and keeps showing the previous active team', async () => { + const user = userEvent.setup() + vi.mocked(auth.switchTeam).mockRejectedValue(new Error('Unable to switch to that team. Please try again or contact your administrator.')) + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + await user.click(await screen.findByRole('menuitemradio', { name: /Proteomics/ })) + + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to switch to that team') + expect(reloadSpy).not.toHaveBeenCalled() + // The top-bar button's own label still reflects the never-actually- + // changed active team -- switchTeam was mocked to reject, so + // cachedUser (and this component's own `user` prop, in a real app + // re-render) never moved off Team A. "Genomics" also appears as a + // menu item label (the dropdown stays open on failure), so scope + // the assertion to the toggle button specifically. + expect(screen.getByRole('button', { name: 'Switch workspace' })).toHaveTextContent('Genomics') + }) + + it('does not expose which org/team boundary caused a 403/404 denial', async () => { + const user = userEvent.setup() + vi.mocked(auth.switchTeam).mockRejectedValue(new Error('Unable to switch to that team. Please try again or contact your administrator.')) + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + await user.click(await screen.findByRole('menuitemradio', { name: /Proteomics/ })) + + const alertText = (await screen.findByRole('alert')).textContent ?? '' + expect(alertText.toLowerCase()).not.toContain('organization') + expect(alertText.toLowerCase()).not.toContain('not a member') + }) + + it('network failure during switch is handled, not reloaded, and reusable (switching re-enabled)', async () => { + const user = userEvent.setup() + vi.mocked(auth.switchTeam).mockRejectedValue(new Error('Unable to switch teams right now. Check your connection and try again.')) + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + await user.click(await screen.findByRole('menuitemradio', { name: /Proteomics/ })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/connection/i) + expect(reloadSpy).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Switch workspace' })).not.toBeDisabled() + }) + + it('prevents duplicate simultaneous switch attempts', async () => { + const user = userEvent.setup() + let resolveSwitch: (u: SessionUser) => void = () => {} + vi.mocked(auth.switchTeam).mockReturnValue(new Promise(resolve => { resolveSwitch = resolve })) + render() + await user.click(screen.getByRole('button', { name: 'Switch workspace' })) + const proteomicsItem = await screen.findByRole('menuitemradio', { name: /Proteomics/ }) + await user.click(proteomicsItem) + + // The top button is disabled while switching -- a second click while + // the first request is still in flight cannot fire another one. + expect(screen.getByRole('button', { name: 'Switch workspace' })).toBeDisabled() + expect(auth.switchTeam).toHaveBeenCalledTimes(1) + + resolveSwitch(makeUser({ teamId: '2' })) + await waitFor(() => expect(reloadSpy).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/frontend/cc-ui/src/components/shell/TeamSwitcher.tsx b/frontend/cc-ui/src/components/shell/TeamSwitcher.tsx new file mode 100644 index 0000000..cc50713 --- /dev/null +++ b/frontend/cc-ui/src/components/shell/TeamSwitcher.tsx @@ -0,0 +1,187 @@ +import { useEffect, useState } from 'react' +import { Check, ChevronDown, Users } from 'lucide-react' +import type { SessionUser } from '../../auth' +import { switchTeam } from '../../auth' +import { listTeams, type Team } from '../../teams' + +/** + * Mode B Phase 2: Workspace Switcher. Same interactive-dropdown shape + * ProfileMenu.tsx already established (useState(open), fixed-inset + * click-outside backdrop, absolute-positioned panel) combined with + * OrgSelector.tsx's icon+label+chevron button styling -- the first real + * (non-placeholder) switcher in this top bar; OrgSelector next to it is + * still an explicit, deliberate placeholder (its own docstring: "no + * organization-switching functionality is implemented"). + * + * Team membership source: listTeams(orgId) (teams.ts, unchanged, the + * same call TeamsCard already uses) filtered to teams whose + * member_user_ids includes the caller's own userId -- there is no + * "teams I belong to" endpoint of its own; this is the existing + * authenticated team data, not a new team source. Requires user.orgId -- + * renders nothing without one (a platform admin with no primary org has + * no single org's teams to resolve against; same "nothing true to show" + * posture OrgSelector already documents for its own no-org case). + * + * Switching itself is entirely auth.ts's switchTeam(): POST + * /auth/switch-team via the existing authenticated fetch path, persist + * the reissued access token, rehydrate cachedUser from it. This + * component never computes, stores, or trusts a team_id itself beyond + * the one round-trip through the server's own response. + * + * On success: a full page reload. Deliberate, not a shortcut -- the + * server-issued token is already persisted before the reload happens, + * so nothing is lost; the reload is what "Navigate/refresh only as + * necessary to guarantee that all team-scoped views use the new JWT" + * (this task's own §2.10) cashes out to as an actual guarantee, without + * this component needing to know which other pages/data are team-scoped + * today or become so later -- the same reasoning production apps with + * an equivalent workspace switch (e.g. a full reload on switching + * GitHub orgs) already rely on, not a novel choice for this codebase. + */ +export default function TeamSwitcher({ user }: { user: SessionUser | null }) { + const [open, setOpen] = useState(false) + const [teams, setTeams] = useState(null) + const [loadError, setLoadError] = useState(null) + const [switching, setSwitching] = useState(false) + const [switchError, setSwitchError] = useState(null) + + const orgId = user?.orgId != null ? Number(user.orgId) : null + const userId = user?.userId != null ? Number(user.userId) : null + + useEffect(() => { + if (orgId == null) { + setTeams(null) + return + } + let cancelled = false + listTeams(orgId) + .then(list => { if (!cancelled) setTeams(list) }) + .catch(() => { if (!cancelled) setLoadError('Teams unavailable') }) + return () => { cancelled = true } + }, [orgId]) + + if (!user || orgId == null) return null + + const myTeams = (teams ?? []).filter(t => userId != null && t.member_user_ids.includes(userId)) + const activeTeam = user.teamId != null ? myTeams.find(t => t.id === Number(user.teamId)) : undefined + const label = user.teamId == null ? 'Personal Workspace' : (activeTeam?.name ?? `Team ${user.teamId}`) + + const handleSwitch = async (teamId: number | null) => { + if (switching) return // race protection: no concurrent switch attempts + if (teamId === (user.teamId != null ? Number(user.teamId) : null)) { + setOpen(false) + return // already active -- nothing to do + } + setSwitching(true) + setSwitchError(null) + try { + await switchTeam(teamId) + // Server confirmed and cachedUser is already rebuilt -- the reload + // just makes every mounted page/query re-run against the new JWT. + // Dropdown is left open/disabled rather than closed here -- the + // page is about to unload regardless, and closing first would + // just flash an interactive-looking menu for a moment with + // nothing behind it to click. + window.location.reload() + } catch (e) { + // Left open (not closed) so the error message below is visible -- + // closing on failure would hide the one piece of feedback the + // caller needs to know the switch didn't happen. + setSwitchError(e instanceof Error ? e.message : 'Unable to switch teams.') + setSwitching(false) + } + } + + return ( +
+ + + {open && ( + <> +
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 149 }} /> +
+
+ Switch workspace +
+ + {loadError && ( +
{loadError}
+ )} + + void handleSwitch(null)} + /> + + {myTeams.length === 0 && !loadError && teams != null && ( +
No team memberships yet.
+ )} + + {[...myTeams].sort((a, b) => a.name.localeCompare(b.name)).map(t => ( + void handleSwitch(t.id)} + /> + ))} + + {switchError && ( +
+ {switchError} +
+ )} +
+ + )} +
+ ) +} + +function SwitcherItem({ label, active, disabled, onClick }: { label: string; active: boolean; disabled: boolean; onClick: () => void }) { + return ( + + ) +} diff --git a/frontend/cc-ui/src/components/shell/TopAppBar.tsx b/frontend/cc-ui/src/components/shell/TopAppBar.tsx index 3202682..d680f09 100644 --- a/frontend/cc-ui/src/components/shell/TopAppBar.tsx +++ b/frontend/cc-ui/src/components/shell/TopAppBar.tsx @@ -5,6 +5,7 @@ import GlobalSearch from './GlobalSearch' import NotificationsMenu from './NotificationsMenu' import OrgSelector from './OrgSelector' import ProfileMenu from './ProfileMenu' +import TeamSwitcher from './TeamSwitcher' import ThemeToggle from './ThemeToggle' interface Props { @@ -20,9 +21,12 @@ interface Props { /** * Admin Console Phase 2: top app bar. Breadcrumb (left) + global search / - * notifications / theme toggle / org selector / profile menu (right) -- - * every one of these is new UI except ProfileMenu's sign-out, which - * reuses auth.ts's existing logout() (see ProfileMenu.tsx). + * notifications / theme toggle / org selector / team switcher / profile + * menu (right) -- every one of these is new UI except ProfileMenu's + * sign-out, which reuses auth.ts's existing logout() (see + * ProfileMenu.tsx). Mode B Phase 2 adds TeamSwitcher, the first real + * (non-placeholder) context switcher here -- OrgSelector next to it + * remains its own documented placeholder. */ export default function TopAppBar({ breadcrumb, user, onSignOut, onMenuToggle, extraActions }: Props) { return ( @@ -47,6 +51,7 @@ export default function TopAppBar({ breadcrumb, user, onSignOut, onMenuToggle, e {extraActions &&
{extraActions}
} + diff --git a/frontend/cc-ui/src/components/shell/index.ts b/frontend/cc-ui/src/components/shell/index.ts index 1a26a60..5455244 100644 --- a/frontend/cc-ui/src/components/shell/index.ts +++ b/frontend/cc-ui/src/components/shell/index.ts @@ -6,5 +6,6 @@ export { default as GlobalSearch } from './GlobalSearch' export { default as NotificationsMenu } from './NotificationsMenu' export { default as OrgSelector } from './OrgSelector' export { default as ProfileMenu } from './ProfileMenu' +export { default as TeamSwitcher } from './TeamSwitcher' export { default as ThemeToggle } from './ThemeToggle' export { default as Footer } from './Footer' diff --git a/frontend/cc-ui/src/components/teams/TeamMembersPanel.test.tsx b/frontend/cc-ui/src/components/teams/TeamMembersPanel.test.tsx index 3f48a19..40b01e9 100644 --- a/frontend/cc-ui/src/components/teams/TeamMembersPanel.test.tsx +++ b/frontend/cc-ui/src/components/teams/TeamMembersPanel.test.tsx @@ -39,7 +39,7 @@ function sessionAs(userId: number | null) { vi.mocked(auth.getSessionUser).mockReturnValue( userId == null ? null : { userId: String(userId), email: 'x@acme.test', roles: [], permissions: [], - orgId: '42', orgRoles: [], schemaVersion: 2, + orgId: '42', orgRoles: [], teamId: null, teamRole: null, schemaVersion: 2, }, ) } diff --git a/frontend/cc-ui/tsconfig.app.tsbuildinfo b/frontend/cc-ui/tsconfig.app.tsbuildinfo index 815c803..82518a1 100644 --- a/frontend/cc-ui/tsconfig.app.tsbuildinfo +++ b/frontend/cc-ui/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/api.ts","./src/audit.ts","./src/auth.test.ts","./src/auth.ts","./src/billing.ts","./src/dashboard.ts","./src/format.ts","./src/integrations.ts","./src/interactions.ts","./src/main.tsx","./src/model_registry.ts","./src/navigation.test.ts","./src/navigation.ts","./src/organizations.ts","./src/platform_config.ts","./src/rag.ts","./src/roles.ts","./src/security.ts","./src/serviceAccounts.ts","./src/sessions.ts","./src/sso.ts","./src/teams.ts","./src/tes.ts","./src/test-setup.ts","./src/users.ts","./src/workflows.ts","./src/apps/AdminApp.test.tsx","./src/apps/AdminApp.tsx","./src/apps/AuthGate.tsx","./src/apps/ControlApp.test.tsx","./src/apps/ControlApp.tsx","./src/apps/UnknownModeNotice.test.tsx","./src/apps/UnknownModeNotice.tsx","./src/components/AccessDenied.test.tsx","./src/components/AccessDenied.tsx","./src/components/AdminLogo.tsx","./src/components/Header.tsx","./src/components/LoginScreen.test.tsx","./src/components/LoginScreen.tsx","./src/components/OAuthButtons.tsx","./src/components/StatusBadge.tsx","./src/components/dashboard/AlertCard.tsx","./src/components/dashboard/DashboardCard.tsx","./src/components/dashboard/DashboardGrid.tsx","./src/components/dashboard/HealthCard.tsx","./src/components/dashboard/MetricCard.tsx","./src/components/dashboard/StatusCard.tsx","./src/components/dashboard/TrendCard.tsx","./src/components/dashboard/dashboard-widgets.test.tsx","./src/components/dashboard/index.ts","./src/components/organizations/OrganizationStatusBadge.tsx","./src/components/organizations/OrganizationSummaryCard.tsx","./src/components/organizations/OrganizationTable.tsx","./src/components/organizations/SecuritySummaryCard.tsx","./src/components/roles/PermissionSelector.test.tsx","./src/components/roles/PermissionSelector.tsx","./src/components/roles/RoleAssignmentList.tsx","./src/components/roles/RoleBadge.tsx","./src/components/roles/RoleSelector.test.tsx","./src/components/roles/RoleSelector.tsx","./src/components/shell/AppShell.tsx","./src/components/shell/Breadcrumb.tsx","./src/components/shell/Footer.tsx","./src/components/shell/GlobalSearch.tsx","./src/components/shell/NotificationsMenu.tsx","./src/components/shell/OrgSelector.tsx","./src/components/shell/ProfileMenu.tsx","./src/components/shell/SidebarNav.tsx","./src/components/shell/ThemeToggle.tsx","./src/components/shell/TopAppBar.tsx","./src/components/shell/index.ts","./src/components/teams/TeamMembersPanel.test.tsx","./src/components/teams/TeamMembersPanel.tsx","./src/components/teams/TeamRow.test.tsx","./src/components/teams/TeamRow.tsx","./src/components/teams/TeamsCard.test.tsx","./src/components/teams/TeamsCard.tsx","./src/components/ui/ActionToolbar.tsx","./src/components/ui/BackLink.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/ComingSoon.tsx","./src/components/ui/DataTable.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/ErrorState.tsx","./src/components/ui/LoadingState.tsx","./src/components/ui/PageContainer.tsx","./src/components/ui/Pagination.tsx","./src/components/ui/SectionHeader.tsx","./src/components/ui/SessionExpiredState.tsx","./src/components/ui/StatCard.tsx","./src/components/ui/icon-type.ts","./src/components/ui/index.ts","./src/components/users/UserMFASecurityCard.test.tsx","./src/components/users/UserMFASecurityCard.tsx","./src/components/users/UserOrgMembershipList.tsx","./src/components/users/UserStatusAction.tsx","./src/pages/CloudPage.tsx","./src/pages/ConfigPage.tsx","./src/pages/DashboardPage.test.tsx","./src/pages/DashboardPage.tsx","./src/pages/DockerPage.tsx","./src/pages/EcosystemPage.tsx","./src/pages/HealthPage.tsx","./src/pages/IntegrationsPage.test.tsx","./src/pages/IntegrationsPage.tsx","./src/pages/InteractionsPage.test.tsx","./src/pages/InteractionsPage.tsx","./src/pages/LlmPage.tsx","./src/pages/OrganizationDetailPage.test.tsx","./src/pages/OrganizationDetailPage.tsx","./src/pages/OrganizationsPage.test.tsx","./src/pages/OrganizationsPage.tsx","./src/pages/PlatformSettingsPage.test.tsx","./src/pages/PlatformSettingsPage.tsx","./src/pages/UserDetailPage.test.tsx","./src/pages/UserDetailPage.tsx","./src/pages/UsersPage.test.tsx","./src/pages/UsersPage.tsx","./src/pages/audit/AuditLogsPage.test.tsx","./src/pages/audit/AuditLogsPage.tsx","./src/pages/billing/BillingPage.test.tsx","./src/pages/billing/BillingPage.tsx","./src/pages/billing/SubscriptionPage.test.tsx","./src/pages/billing/SubscriptionPage.tsx","./src/pages/identity/RolesPage.test.tsx","./src/pages/identity/RolesPage.tsx","./src/pages/identity/SSOSettingsPage.test.tsx","./src/pages/identity/SSOSettingsPage.tsx","./src/pages/identity/ServiceAccountsPage.test.tsx","./src/pages/identity/ServiceAccountsPage.tsx","./src/pages/identity/TeamsPage.test.tsx","./src/pages/identity/TeamsPage.tsx","./src/pages/operations/AIModelsPage.test.tsx","./src/pages/operations/AIModelsPage.tsx","./src/pages/operations/RAGPage.test.tsx","./src/pages/operations/RAGPage.tsx","./src/pages/operations/ToolExecutionPage.test.tsx","./src/pages/operations/ToolExecutionPage.tsx","./src/pages/operations/WorkflowsPage.test.tsx","./src/pages/operations/WorkflowsPage.tsx","./src/pages/security/OrganizationMFAPolicyPage.test.tsx","./src/pages/security/OrganizationMFAPolicyPage.tsx","./src/pages/security/SecurityDashboardPage.test.tsx","./src/pages/security/SecurityDashboardPage.tsx","./src/pages/security/SessionsPage.test.tsx","./src/pages/security/SessionsPage.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/api.ts","./src/audit.ts","./src/auth.test.ts","./src/auth.ts","./src/billing.ts","./src/dashboard.ts","./src/format.ts","./src/integrations.ts","./src/interactions.ts","./src/main.tsx","./src/model_registry.ts","./src/navigation.test.ts","./src/navigation.ts","./src/organizations.ts","./src/platform_config.ts","./src/rag.ts","./src/roles.ts","./src/security.ts","./src/serviceAccounts.ts","./src/sessions.ts","./src/sso.ts","./src/teams.ts","./src/tes.ts","./src/test-setup.ts","./src/users.ts","./src/workflows.ts","./src/apps/AdminApp.test.tsx","./src/apps/AdminApp.tsx","./src/apps/AuthGate.tsx","./src/apps/ControlApp.test.tsx","./src/apps/ControlApp.tsx","./src/apps/UnknownModeNotice.test.tsx","./src/apps/UnknownModeNotice.tsx","./src/components/AccessDenied.test.tsx","./src/components/AccessDenied.tsx","./src/components/AdminLogo.tsx","./src/components/Header.tsx","./src/components/LoginScreen.test.tsx","./src/components/LoginScreen.tsx","./src/components/OAuthButtons.tsx","./src/components/StatusBadge.tsx","./src/components/dashboard/AlertCard.tsx","./src/components/dashboard/DashboardCard.tsx","./src/components/dashboard/DashboardGrid.tsx","./src/components/dashboard/HealthCard.tsx","./src/components/dashboard/MetricCard.tsx","./src/components/dashboard/StatusCard.tsx","./src/components/dashboard/TrendCard.tsx","./src/components/dashboard/dashboard-widgets.test.tsx","./src/components/dashboard/index.ts","./src/components/organizations/OrganizationStatusBadge.tsx","./src/components/organizations/OrganizationSummaryCard.tsx","./src/components/organizations/OrganizationTable.tsx","./src/components/organizations/SecuritySummaryCard.tsx","./src/components/roles/PermissionSelector.test.tsx","./src/components/roles/PermissionSelector.tsx","./src/components/roles/RoleAssignmentList.tsx","./src/components/roles/RoleBadge.tsx","./src/components/roles/RoleSelector.test.tsx","./src/components/roles/RoleSelector.tsx","./src/components/shell/AppShell.tsx","./src/components/shell/Breadcrumb.tsx","./src/components/shell/Footer.tsx","./src/components/shell/GlobalSearch.tsx","./src/components/shell/NotificationsMenu.tsx","./src/components/shell/OrgSelector.tsx","./src/components/shell/ProfileMenu.tsx","./src/components/shell/SidebarNav.tsx","./src/components/shell/TeamSwitcher.test.tsx","./src/components/shell/TeamSwitcher.tsx","./src/components/shell/ThemeToggle.tsx","./src/components/shell/TopAppBar.tsx","./src/components/shell/index.ts","./src/components/teams/TeamMembersPanel.test.tsx","./src/components/teams/TeamMembersPanel.tsx","./src/components/teams/TeamRow.test.tsx","./src/components/teams/TeamRow.tsx","./src/components/teams/TeamsCard.test.tsx","./src/components/teams/TeamsCard.tsx","./src/components/ui/ActionToolbar.tsx","./src/components/ui/BackLink.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/ComingSoon.tsx","./src/components/ui/DataTable.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/ErrorState.tsx","./src/components/ui/LoadingState.tsx","./src/components/ui/PageContainer.tsx","./src/components/ui/Pagination.tsx","./src/components/ui/SectionHeader.tsx","./src/components/ui/SessionExpiredState.tsx","./src/components/ui/StatCard.tsx","./src/components/ui/icon-type.ts","./src/components/ui/index.ts","./src/components/users/UserMFASecurityCard.test.tsx","./src/components/users/UserMFASecurityCard.tsx","./src/components/users/UserOrgMembershipList.tsx","./src/components/users/UserStatusAction.tsx","./src/pages/CloudPage.tsx","./src/pages/ConfigPage.tsx","./src/pages/DashboardPage.test.tsx","./src/pages/DashboardPage.tsx","./src/pages/DockerPage.tsx","./src/pages/EcosystemPage.tsx","./src/pages/HealthPage.tsx","./src/pages/IntegrationsPage.test.tsx","./src/pages/IntegrationsPage.tsx","./src/pages/InteractionsPage.test.tsx","./src/pages/InteractionsPage.tsx","./src/pages/LlmPage.tsx","./src/pages/OrganizationDetailPage.test.tsx","./src/pages/OrganizationDetailPage.tsx","./src/pages/OrganizationsPage.test.tsx","./src/pages/OrganizationsPage.tsx","./src/pages/PlatformSettingsPage.test.tsx","./src/pages/PlatformSettingsPage.tsx","./src/pages/UserDetailPage.test.tsx","./src/pages/UserDetailPage.tsx","./src/pages/UsersPage.test.tsx","./src/pages/UsersPage.tsx","./src/pages/audit/AuditLogsPage.test.tsx","./src/pages/audit/AuditLogsPage.tsx","./src/pages/billing/BillingPage.test.tsx","./src/pages/billing/BillingPage.tsx","./src/pages/billing/SubscriptionPage.test.tsx","./src/pages/billing/SubscriptionPage.tsx","./src/pages/identity/RolesPage.test.tsx","./src/pages/identity/RolesPage.tsx","./src/pages/identity/SSOSettingsPage.test.tsx","./src/pages/identity/SSOSettingsPage.tsx","./src/pages/identity/ServiceAccountsPage.test.tsx","./src/pages/identity/ServiceAccountsPage.tsx","./src/pages/identity/TeamsPage.test.tsx","./src/pages/identity/TeamsPage.tsx","./src/pages/operations/AIModelsPage.test.tsx","./src/pages/operations/AIModelsPage.tsx","./src/pages/operations/RAGPage.test.tsx","./src/pages/operations/RAGPage.tsx","./src/pages/operations/ToolExecutionPage.test.tsx","./src/pages/operations/ToolExecutionPage.tsx","./src/pages/operations/WorkflowsPage.test.tsx","./src/pages/operations/WorkflowsPage.tsx","./src/pages/security/OrganizationMFAPolicyPage.test.tsx","./src/pages/security/OrganizationMFAPolicyPage.tsx","./src/pages/security/SecurityDashboardPage.test.tsx","./src/pages/security/SecurityDashboardPage.tsx","./src/pages/security/SessionsPage.test.tsx","./src/pages/security/SessionsPage.tsx"],"version":"5.9.3"} \ No newline at end of file