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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,44 @@ void impersonatingDbaAllowsMutateSql() {
assertFalse(accessControlService.isCurrentUserAdmin());
}

/**
* An admin viewing as a user who lacks access to a connection sees access denied.
*
* <p>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,
Expand Down
23 changes: 19 additions & 4 deletions src/components/sections/AgentChatSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div style={{ padding: 40, color: '#6b7280', fontSize: 14 }}>
Loading connections…
</div>
)
}

// 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 (
<div style={{ padding: 40, color: '#6b7280', fontSize: 14 }}>
Select a database connection to chat with the DeepSQL Agent.
Expand All @@ -20,8 +35,8 @@ export default function AgentChatSection() {
<AgentChatPanel
key={`${username || 'anon'}:${connectionId}`}
connectionId={connectionId}
connectionName={selectedConnection?.connectionName}
canManageContent={Boolean(selectedConnection?.canManageContent)}
connectionName={selectedConnection.connectionName}
canManageContent={Boolean(selectedConnection.canManageContent)}
/>
)
}
16 changes: 13 additions & 3 deletions src/components/sections/CompanyKnowledgeSection.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.root}>
<Loader2 size={24} color="#9ca3af" className={styles.spin} />
<p className={styles.subtitle}>Loading connections…</p>
</div>
)
}

// Either no connection or stale connectionId not in the effective user's list
if (!connectionId || !selectedConnection) {
return (
<div className={styles.root}>
<div className={styles.iconWrap}><Building2 size={26} color="#9ca3af" /></div>
Expand Down
16 changes: 13 additions & 3 deletions src/components/sections/DashboardsSection.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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

Expand All @@ -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 (
<div className={emptyStyles.root}>
<Loader2 size={24} color="#9ca3af" className={emptyStyles.spin} />
<p className={emptyStyles.subtitle}>Loading connections…</p>
</div>
)
}

// Either no connection or stale connectionId not in the effective user's list
if (!connectionId || !selectedConnection) {
return (
<div className={emptyStyles.root}>
<div className={emptyStyles.iconWrap}><LayoutDashboard size={26} color="#9ca3af" /></div>
Expand Down
29 changes: 25 additions & 4 deletions src/components/sections/DigestSection.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -257,6 +257,26 @@ export default function DigestFeedSection() {
}
}

// Wait for connection list to load first
if (connectionsLoading) {
return (
<div className={styles.root}>
<div className={styles.topBar}>
<div className={styles.topBarLeft}>
<Newspaper size={17} className={styles.topBarIcon} />
<span className={styles.topBarTitle}>DB Digest</span>
</div>
</div>
<div className={styles.feed}>
<div className={styles.loadingState}>
<Loader2 size={20} className={styles.spinning} color="#9ca3af" />
<span>Loading connections…</span>
</div>
</div>
</div>
)
}

return (
<div className={styles.root}>
{/* Top bar */}
Expand Down Expand Up @@ -309,15 +329,16 @@ export default function DigestFeedSection() {
</div>
)}

{!error && !loading && !connectionId && (
{/* Either no connection or stale connectionId not in the effective user's list */}
{!error && !loading && (!connectionId || !selectedConnection) && (
<div className={styles.emptyState}>
<Newspaper size={32} color="#d1d5db" />
<h3>No connection selected</h3>
<p>Select a connection to view its digest history.</p>
</div>
)}

{!error && !loading && !!connectionId && digests.length === 0 && (
{!error && !loading && !!connectionId && !!selectedConnection && digests.length === 0 && (
<div className={styles.emptyState}>
<Newspaper size={32} color="#d1d5db" />
<h3>No digests yet</h3>
Expand Down
16 changes: 13 additions & 3 deletions src/components/sections/EditorSection.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.root}>
<Loader2 size={24} color="#9ca3af" className={styles.spin} />
<p className={styles.subtitle}>Loading connections…</p>
</div>
)
}

// Either no connection selected, or stale connectionId not in the current user's list
if (!connectionId || !selectedConnection) {
return (
<div className={styles.root}>
<div className={styles.iconWrap}>
Expand Down
17 changes: 14 additions & 3 deletions src/components/sections/MonitorSection.jsx
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -27,7 +27,18 @@ export default function MonitorSection() {
.finally(() => setLoading(false))
}, [connectionId])

if (!connectionId) {
// Wait for connection list to load first
if (connectionsLoading) {
return (
<div className={styles.root}>
<Loader2 size={24} color="#9ca3af" className={styles.spin} />
<p className={styles.subtitle}>Loading connections…</p>
</div>
)
}

// Either no connection or stale connectionId not in the effective user's list
if (!connectionId || !selectedConnection) {
return (
<div className={styles.root}>
<div className={styles.iconWrap}>
Expand Down
16 changes: 13 additions & 3 deletions src/components/sections/SchemaDocsSection.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 (
<div className={styles.root}>
<Loader2 size={24} color="#9ca3af" className={styles.spin} />
<p className={styles.subtitle}>Loading connections…</p>
</div>
)
}

// Either no connection or stale connectionId not in the effective user's list
if (!connectionId || !selectedConnection) {
return (
<div className={styles.root}>
<div className={styles.iconWrap}><FileText size={26} color="#9ca3af" /></div>
Expand Down
16 changes: 13 additions & 3 deletions src/components/sections/SchemaSection.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.root}>
<Loader2 size={24} color="#9ca3af" className={styles.spin} />
<p className={styles.subtitle}>Loading connections…</p>
</div>
)
}

// Either no connection or stale connectionId not in the effective user's list
if (!connectionId || !selectedConnection) {
return (
<div className={styles.root}>
<div className={styles.iconWrap}><Network size={26} color="#9ca3af" /></div>
Expand Down
8 changes: 8 additions & 0 deletions src/components/sections/SectionEmpty.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@
background: #f9fafb;
}

.spin {
animation: sectionSpin 1s linear infinite;
}

@keyframes sectionSpin {
to { transform: rotate(360deg); }
}

.pills {
display: flex;
gap: 8px;
Expand Down
23 changes: 20 additions & 3 deletions src/components/sections/SlowQueriesSection.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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({})

Expand Down Expand Up @@ -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 (
<div className={sectionStyles.page}>
<div className={sectionStyles.header}>
<div className={sectionStyles.eyebrow}>Performance</div>
<h1 className={sectionStyles.title}>Slow queries &amp; workload</h1>
</div>
<div className={styles.empty}>
<Loader2 size={20} className={styles.spinIcon} />
Loading connections…
</div>
</div>
)
}

return (
<div className={sectionStyles.page}>
<div className={sectionStyles.header}>
Expand All @@ -70,7 +86,8 @@ export default function SlowQueriesSection() {
</p>
</div>

{!connectionId ? (
{/* Either no connection or stale connectionId not in the effective user's list */}
{(!connectionId || !selectedConnection) ? (
<div className={styles.empty}>
Select a database connection to see performance analytics.
</div>
Expand Down
Loading
Loading