diff --git a/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java b/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java index 5551446..afc149f 100644 --- a/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java +++ b/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java @@ -359,6 +359,44 @@ void impersonatingDbaAllowsMutateSql() { assertFalse(accessControlService.isCurrentUserAdmin()); } + /** + * An admin viewing as a user who lacks access to a connection sees access denied. + * + *

This is the backend counterpart to the frontend fix: when the admin selects a + * connection, then uses "View as" to switch to a user without access to that connection, + * the UI should not be able to make API calls against the connection. The UI fix clears + * the stale connectionId; this test ensures the backend also correctly denies access. + */ + @Test + void impersonatingUserWithoutAccessDeniesConnection() { + com.dbaagent.model.User impersonator = new com.dbaagent.model.User(); + impersonator.setId(1L); + impersonator.setUsername("admin"); + impersonator.setRole("ADMIN"); + com.dbaagent.model.User target = new com.dbaagent.model.User(); + target.setId(2L); + target.setUsername("mart-viewer"); + target.setRole("DEVELOPER"); + com.dbaagent.security.ImpersonationContext.enter( + new com.dbaagent.security.ImpersonationContext.State(impersonator, target) + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("mart-viewer", null, List.of()) + ); + + // The target user has no grant on conn-1 + when(connectionAccessService.resolveAccess("conn-1", "mart-viewer", false)) + .thenReturn(resolved("conn-1", EffectiveConnectionAccess.NONE, null)); + + // Verify: admin bypass is disabled during impersonation + assertFalse(accessControlService.isCurrentUserAdmin()); + + // Verify: attempting to access the connection should throw 403 + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> accessControlService.assertCanUseChatEditor("conn-1")); + assertEquals(403, ex.getStatusCode().value()); + } + private ConnectionAccessService.ResolvedConnectionAccess resolved( String connectionId, EffectiveConnectionAccess effectiveAccess, diff --git a/src/components/sections/AgentChatSection.jsx b/src/components/sections/AgentChatSection.jsx index de4fd44..4f08c53 100644 --- a/src/components/sections/AgentChatSection.jsx +++ b/src/components/sections/AgentChatSection.jsx @@ -3,10 +3,25 @@ import { useAuth } from '@/hooks/useAuth' import AgentChatPanel from '@/components/AgentChat/AgentChatPanel' export default function AgentChatSection() { - const { connectionId, selectedConnection } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() const { username } = useAuth() - if (!connectionId) { + // Wait for the connection list to load before rendering anything. Without this, + // we might render the agent panel with a stale connectionId from before an + // impersonation change — the new user's connection list hasn't loaded yet, so + // selectedConnection is undefined, but connectionId is still the old value. + if (isLoading) { + return ( +

+ Loading connections… +
+ ) + } + + // Either no connection selected, or the selected connectionId is not in the + // current user's connections (stale after impersonation). Both cases mean the + // user needs to pick a valid connection before chatting. + if (!connectionId || !selectedConnection) { return (
Select a database connection to chat with the DeepSQL Agent. @@ -20,8 +35,8 @@ export default function AgentChatSection() { ) } diff --git a/src/components/sections/CompanyKnowledgeSection.jsx b/src/components/sections/CompanyKnowledgeSection.jsx index 9ed0bc2..0b69702 100644 --- a/src/components/sections/CompanyKnowledgeSection.jsx +++ b/src/components/sections/CompanyKnowledgeSection.jsx @@ -1,12 +1,22 @@ -import { Building2 } from 'lucide-react' +import { Building2, Loader2 } from 'lucide-react' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import CompanyKnowledgePanel from '@/components/company-knowledge/CompanyKnowledgePanel' import styles from './SectionEmpty.module.css' export default function CompanyKnowledgeSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() - if (!connectionId) { + if (isLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection or stale connectionId not in the effective user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/DashboardsSection.jsx b/src/components/sections/DashboardsSection.jsx index c2476c6..efc770c 100644 --- a/src/components/sections/DashboardsSection.jsx +++ b/src/components/sections/DashboardsSection.jsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react' -import { LayoutDashboard } from 'lucide-react' +import { LayoutDashboard, Loader2 } from 'lucide-react' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import { useSetImmersive } from '@/lib/stores/useNavStore' import DashboardsHome from './DashboardsHome' @@ -10,7 +10,7 @@ import emptyStyles from './SectionEmpty.module.css' // dashboard, a focused full-bleed builder workspace (sidebar hidden via the // nav store's immersive flag). export default function DashboardsSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() const setImmersive = useSetImmersive() const [open_, setOpen] = useState(null) // null = gallery; 'new' | dashboard object = workspace @@ -22,7 +22,17 @@ export default function DashboardsSection() { // Safety: never leave the app in immersive mode when this section unmounts. useEffect(() => () => setImmersive(false), [setImmersive]) - if (!connectionId) { + if (isLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection or stale connectionId not in the effective user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/DigestSection.jsx b/src/components/sections/DigestSection.jsx index e8f89a5..8bdcaf4 100644 --- a/src/components/sections/DigestSection.jsx +++ b/src/components/sections/DigestSection.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { Newspaper, RefreshCw, Settings, Check, Clock, AlertCircle, Zap } from 'lucide-react' +import { Loader2, Newspaper, RefreshCw, Settings, Check, Clock, AlertCircle, Zap } from 'lucide-react' import { slackDigestAPI, digestPreferencesAPI } from '@/lib/api/client' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import DigestPreferencesPanel from './DigestPreferencesPanel' @@ -165,7 +165,7 @@ function DigestSection({ section }) { const DIGEST_PREFS_AUTOPEN_KEY = 'deepsql.digestPrefs.autoOpened.v1' export default function DigestFeedSection() { - const { connectionId, selectedConnection } = useConnectionManager() + const { connectionId, selectedConnection, isLoading: connectionsLoading } = useConnectionManager() const [digests, setDigests] = useState([]) const [loading, setLoading] = useState(false) const [triggering, setTriggering] = useState(false) @@ -257,6 +257,26 @@ export default function DigestFeedSection() { } } + // Wait for connection list to load first + if (connectionsLoading) { + return ( +
+
+
+ + DB Digest +
+
+
+
+ + Loading connections… +
+
+
+ ) + } + return (
{/* Top bar */} @@ -309,7 +329,8 @@ export default function DigestFeedSection() {
)} - {!error && !loading && !connectionId && ( + {/* Either no connection or stale connectionId not in the effective user's list */} + {!error && !loading && (!connectionId || !selectedConnection) && (

No connection selected

@@ -317,7 +338,7 @@ export default function DigestFeedSection() {
)} - {!error && !loading && !!connectionId && digests.length === 0 && ( + {!error && !loading && !!connectionId && !!selectedConnection && digests.length === 0 && (

No digests yet

diff --git a/src/components/sections/EditorSection.jsx b/src/components/sections/EditorSection.jsx index 9229c20..a3e1fee 100644 --- a/src/components/sections/EditorSection.jsx +++ b/src/components/sections/EditorSection.jsx @@ -1,12 +1,22 @@ import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import SqlRunnerTab from '@/components/tabs/Core/SqlRunnerTab' -import { Code2 } from 'lucide-react' +import { Code2, Loader2 } from 'lucide-react' import styles from './SectionEmpty.module.css' export default function EditorSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() - if (!connectionId) { + if (isLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection selected, or stale connectionId not in the current user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/MonitorSection.jsx b/src/components/sections/MonitorSection.jsx index 61022fa..6fe8685 100644 --- a/src/components/sections/MonitorSection.jsx +++ b/src/components/sections/MonitorSection.jsx @@ -1,12 +1,12 @@ import { useEffect, useState } from 'react' -import { BarChart2 } from 'lucide-react' +import { BarChart2, Loader2 } from 'lucide-react' import { slowQueriesAPI } from '@/lib/api/client' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import AnalyticsTab from '@/components/tabs/Monitoring/AnalyticsTab' import styles from './SectionEmpty.module.css' export default function MonitorSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading: connectionsLoading } = useConnectionManager() const [hasData, setHasData] = useState(null) // null = loading const [loading, setLoading] = useState(true) @@ -27,7 +27,18 @@ export default function MonitorSection() { .finally(() => setLoading(false)) }, [connectionId]) - if (!connectionId) { + // Wait for connection list to load first + if (connectionsLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection or stale connectionId not in the effective user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/SchemaDocsSection.jsx b/src/components/sections/SchemaDocsSection.jsx index b54deba..19f97a0 100644 --- a/src/components/sections/SchemaDocsSection.jsx +++ b/src/components/sections/SchemaDocsSection.jsx @@ -1,4 +1,4 @@ -import { FileText } from 'lucide-react' +import { FileText, Loader2 } from 'lucide-react' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import { useSetActiveSection } from '@/lib/stores/useNavStore' import { useCompanyKnowledgeStore } from '@/lib/stores/useCompanyKnowledgeStore' @@ -7,11 +7,21 @@ import styles from './SectionEmpty.module.css' import workspaceStyles from './TopLevelSection.module.css' export default function SchemaDocsSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() const setActiveSection = useSetActiveSection() const setLinkedFilters = useCompanyKnowledgeStore((state) => state.setLinkedFilters) - if (!connectionId) { + if (isLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection or stale connectionId not in the effective user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/SchemaSection.jsx b/src/components/sections/SchemaSection.jsx index ea310fb..81488d7 100644 --- a/src/components/sections/SchemaSection.jsx +++ b/src/components/sections/SchemaSection.jsx @@ -1,12 +1,22 @@ -import { Network } from 'lucide-react' +import { Network, Loader2 } from 'lucide-react' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import BrainWorkspace from '@/components/tabs/Brain/BrainWorkspace' import styles from './SectionEmpty.module.css' export default function SchemaSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() - if (!connectionId) { + if (isLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection or stale connectionId not in the effective user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/SectionEmpty.module.css b/src/components/sections/SectionEmpty.module.css index 6c9435b..db096c1 100644 --- a/src/components/sections/SectionEmpty.module.css +++ b/src/components/sections/SectionEmpty.module.css @@ -72,6 +72,14 @@ background: #f9fafb; } +.spin { + animation: sectionSpin 1s linear infinite; +} + +@keyframes sectionSpin { + to { transform: rotate(360deg); } +} + .pills { display: flex; gap: 8px; diff --git a/src/components/sections/SlowQueriesSection.jsx b/src/components/sections/SlowQueriesSection.jsx index 44af4c0..7cfbe58 100644 --- a/src/components/sections/SlowQueriesSection.jsx +++ b/src/components/sections/SlowQueriesSection.jsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react' -import { Activity, FileText, LineChart, Settings, Users } from 'lucide-react' +import { Activity, FileText, LineChart, Loader2, Settings, Users } from 'lucide-react' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import { useSlowLogSourceConfig } from '@/lib/hooks/queries' import QueryTrendsTab from '@/components/tabs/Performance/QueryTrendsTab' @@ -31,7 +31,7 @@ const LOG_SOURCE_HELP = { * is a single empty state whose CTA opens SlowQuerySourceModal. */ export default function SlowQueriesSection() { - const { connectionId, selectedConnection } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() const [tab, setTab] = useState('trends') const tabRefs = useRef({}) @@ -59,6 +59,22 @@ export default function SlowQueriesSection() { const logSourceQ = useSlowLogSourceConfig(connectionId) const hasLogSource = Boolean(logSourceQ.data?.id) + // Wait for connection list to load before rendering anything + if (isLoading) { + return ( +
+
+
Performance
+

Slow queries & workload

+
+
+ + Loading connections… +
+
+ ) + } + return (
@@ -70,7 +86,8 @@ export default function SlowQueriesSection() {

- {!connectionId ? ( + {/* Either no connection or stale connectionId not in the effective user's list */} + {(!connectionId || !selectedConnection) ? (
Select a database connection to see performance analytics.
diff --git a/src/components/sections/SlowQueriesSection.module.css b/src/components/sections/SlowQueriesSection.module.css index 3b356ad..3f89f5f 100644 --- a/src/components/sections/SlowQueriesSection.module.css +++ b/src/components/sections/SlowQueriesSection.module.css @@ -141,3 +141,14 @@ border-color: #d1d5db; color: #111827; } + +.spinIcon { + animation: spin 1s linear infinite; + display: inline-block; + vertical-align: middle; + margin-right: 8px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/src/lib/hooks/useConnectionManager.js b/src/lib/hooks/useConnectionManager.js index 0f497a0..59a76e2 100644 --- a/src/lib/hooks/useConnectionManager.js +++ b/src/lib/hooks/useConnectionManager.js @@ -56,6 +56,11 @@ export function useConnectionManager() { // selected and a pin that only ran on a blank slate would never actually be the // default — which is the whole feature. It applies once per page load // (`pinAppliedThisLoad`), so switching connections mid-session still sticks. + // + // Critical: if `connectionId` is set but NOT in the connections list (e.g. after + // "View as" switches to a user without access to that connection), clear it and + // select a valid one. Otherwise the UI keeps a stale connection the effective user + // cannot access, and chat/agent calls 403. useEffect(() => { if (connections.length === 0) return @@ -69,12 +74,23 @@ export function useConnectionManager() { } } - if (connectionId) return + // Validate the selected connection is actually in the current user's list. + // When impersonation changes, the connection list is for the target user, but + // connectionId might still be a connection the admin had selected. Clear it + // so the user must pick from their own list (or gets auto-selected below). + if (connectionId) { + const stillValid = connections.some((c) => c.id === connectionId) + if (stillValid) return + // The selected connection is not in the list — clear it and fall through + // to auto-select from the valid options. + localStorage.removeItem('selectedConnectionId') + setConnectionId(null) + } const savedId = localStorage.getItem('selectedConnectionId') const saved = savedId ? connections.find((c) => c.id === savedId) : null selectConnection((pinned || saved || connections[0]).id) - }, [connections, connectionId, selectConnection]) + }, [connections, connectionId, selectConnection, setConnectionId]) const changeConnection = useCallback( (connId) => {