Skip to content

Commit 833a951

Browse files
tianzhouclaude
andauthored
feat: add audit log page (#23)
* feat: add audit log page Add an admin-only, per-connection audit log page at /audit-log: - New AuditLog page with a connection dropdown filter that drives the connectionId query param, gated on the connection's admin permission - Route wired in App.tsx; connection-validation redirect generalized to cover connection-scoped routes (/ and /audit-log) - Sidebar footer entry point: an audit-log icon plus the app version, with the git commit hash shown on hover Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address review feedback on audit log page - AuditLog: defer the admin gate until connections load so admins don't flash the denied state; keep the connection dropdown outside the gate so non-admins can switch to a connection they can access - App: gate useEditorNavigation to the editor route so /audit-log no longer writes editor URL params (schema/object) or fires schema/table queries; reset the invalid-connection toast guard on valid connections - useEditorNavigation/useQuery: thread an enabled flag through to disable schema/table queries and the URL-sync effect when inactive - ObjectSidebar: add aria-label to the audit-log icon button and make the version tooltip trigger keyboard-focusable Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: handle connection load error and empty list on audit log page Resolve useConnections() error and empty-list states explicitly before the admin gate, so a query error no longer shows "Loading…" forever and zero connections shows a clear message instead of "need admin permission". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: use semantic button for version tooltip trigger Replace the focusable <span tabIndex=0> with a <button type="button"> so the version/commit tooltip trigger has proper interactive semantics for keyboard and screen-reader users. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4fea19c commit 833a951

5 files changed

Lines changed: 138 additions & 15 deletions

File tree

src/App.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import SignIn from './components/SignIn';
55
import Header from './components/Header';
66
import { useSession } from './lib/auth-client';
77
import { SQLEditorLayout } from './components/sql-editor';
8+
import AuditLog from './pages/AuditLog';
89
import { useConnections } from './hooks/useQuery';
910
import { ToastProvider, toastManager } from './components/ui/toast';
1011
import { useEditorTabs } from './components/sql-editor/hooks/useEditorTabs';
@@ -25,7 +26,10 @@ function AppLayout() {
2526

2627
const connectionIdFromUrl = searchParams.get('connectionId');
2728
const isEditorRoute = location.pathname === '/';
29+
const isAuditLogRoute = location.pathname === '/audit-log';
2830
const isSignInRoute = location.pathname === '/signin';
31+
// Routes that are scoped to a connection via the connectionId query param.
32+
const isConnectionScopedRoute = isEditorRoute || isAuditLogRoute;
2933

3034
const selectedConnectionId = (() => {
3135
if (!connections || connections.length === 0) return '';
@@ -35,20 +39,29 @@ function AppLayout() {
3539
return connections[0].id;
3640
})();
3741

38-
// Centralized URL state management - handles defaults and redirects
39-
const navigation = useEditorNavigation(selectedConnectionId);
42+
// Centralized URL state management - handles defaults and redirects.
43+
// Only active on the editor route so it doesn't write editor params
44+
// (schema/object) or fetch schemas while on other connection-scoped routes.
45+
const navigation = useEditorNavigation(selectedConnectionId, isEditorRoute);
4046

4147
const editorTabs = useEditorTabs(selectedConnectionId);
4248

4349
// Validate connection ID and redirect if invalid
4450
useEffect(() => {
45-
if (!connections || connections.length === 0 || !isEditorRoute) return;
51+
if (!connections || connections.length === 0 || !isConnectionScopedRoute) return;
4652

4753
const isValidConnection = connectionIdFromUrl && connections.some(c => c.id === connectionIdFromUrl);
4854

55+
// Reset the once-per-invalid guard whenever we land on a valid connection,
56+
// so a later invalid connectionId (on any scoped route) surfaces its toast.
57+
if (isValidConnection) {
58+
hasShownInvalidToast.current = false;
59+
return;
60+
}
61+
4962
if (!connectionIdFromUrl) {
50-
navigate(`/?connectionId=${connections[0].id}`, { replace: true });
51-
} else if (!isValidConnection) {
63+
navigate(`${location.pathname}?connectionId=${connections[0].id}`, { replace: true });
64+
} else {
5265
if (!hasShownInvalidToast.current) {
5366
hasShownInvalidToast.current = true;
5467
toastManager.add({
@@ -57,9 +70,9 @@ function AppLayout() {
5770
type: 'error',
5871
});
5972
}
60-
navigate(`/?connectionId=${connections[0].id}`, { replace: true });
73+
navigate(`${location.pathname}?connectionId=${connections[0].id}`, { replace: true });
6174
}
62-
}, [connections, connectionIdFromUrl, isEditorRoute, navigate]);
75+
}, [connections, connectionIdFromUrl, isConnectionScopedRoute, location.pathname, navigate]);
6376

6477
if (sessionPending) {
6578
return (
@@ -127,6 +140,9 @@ function AppLayout() {
127140
tablesError={navigation.tablesError}
128141
/>
129142
} />
143+
<Route path="/audit-log" element={
144+
<AuditLog connectionId={selectedConnectionId} />
145+
} />
130146
<Route path="/signin" element={
131147
<div className="flex flex-1 items-center justify-center">
132148
<SignIn />

src/components/sql-editor/ObjectSidebar.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState } from 'react'
2-
import { RefreshCw } from 'lucide-react'
2+
import { useNavigate } from 'react-router-dom'
3+
import { RefreshCw, ScrollText } from 'lucide-react'
34
import { useQueryClient } from '@tanstack/react-query'
45
import { SearchInput } from '../ui/search-input'
56
import { SchemaSelector } from './SchemaSelector'
@@ -51,6 +52,7 @@ export function ObjectSidebar({
5152
}: ObjectSidebarProps) {
5253
const [searchQuery, setSearchQuery] = useState('')
5354
const queryClient = useQueryClient()
55+
const navigate = useNavigate()
5456

5557
// AI schema cache refresh
5658
const { mutate: refreshSchemaCache } = useRefreshSchemaCache()
@@ -164,6 +166,40 @@ export function ObjectSidebar({
164166
connectionId={connectionId}
165167
/>
166168
)}
169+
<div className="flex items-center justify-between border-t border-gray-200 px-2 py-1.5">
170+
<Tooltip>
171+
<TooltipTrigger
172+
render={
173+
<Button
174+
variant="ghost"
175+
size="icon-sm"
176+
aria-label="Audit Log"
177+
onClick={() => navigate(`/audit-log?connectionId=${connectionId}`)}
178+
>
179+
<ScrollText className="h-4 w-4" />
180+
</Button>
181+
}
182+
/>
183+
<TooltipContent side="top">
184+
Audit Log
185+
</TooltipContent>
186+
</Tooltip>
187+
<Tooltip>
188+
<TooltipTrigger
189+
render={
190+
<button
191+
type="button"
192+
className="text-xs text-gray-400 outline-none cursor-default rounded focus-visible:ring-2 focus-visible:ring-ring"
193+
>
194+
v{__APP_VERSION__}
195+
</button>
196+
}
197+
/>
198+
<TooltipContent side="top">
199+
Git commit {__GIT_COMMIT__}
200+
</TooltipContent>
201+
</Tooltip>
202+
</div>
167203
</div>
168204
)
169205
}

src/hooks/useEditorNavigation.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ interface UseEditorNavigationResult {
3232
setObject: (object: SelectedObject | null, options?: { replace?: boolean }) => void
3333
}
3434

35-
export function useEditorNavigation(connectionId: string): UseEditorNavigationResult {
35+
export function useEditorNavigation(connectionId: string, enabled = true): UseEditorNavigationResult {
3636
const [searchParams, setSearchParams] = useSearchParams()
3737
const prevConnectionIdRef = useRef<string | null>(null)
3838

@@ -46,7 +46,7 @@ export function useEditorNavigation(connectionId: string): UseEditorNavigationRe
4646
isLoading: isSchemasLoading,
4747
isFetching: isSchemasFetching,
4848
error: schemasError,
49-
} = useSchemas(connectionId)
49+
} = useSchemas(connectionId, enabled)
5050

5151
// Determine the effective schema (from URL or default)
5252
const effectiveSchema = schemaFromUrl && schemas.includes(schemaFromUrl)
@@ -61,7 +61,7 @@ export function useEditorNavigation(connectionId: string): UseEditorNavigationRe
6161
isLoading: isTablesLoading,
6262
isFetching: isTablesFetching,
6363
error: tablesError,
64-
} = useTables(connectionId, effectiveSchema || '')
64+
} = useTables(connectionId, effectiveSchema || '', enabled)
6565

6666
// Determine the effective object (from URL or default)
6767
const effectiveObject: SelectedObject | null = (() => {
@@ -108,6 +108,9 @@ export function useEditorNavigation(connectionId: string): UseEditorNavigationRe
108108

109109
// Update URL when resolved state differs from URL state
110110
useEffect(() => {
111+
// Skip entirely when this hook isn't driving the current route (e.g. /audit-log),
112+
// so editor-specific params (schema/object) aren't written to unrelated URLs.
113+
if (!enabled) return
111114
// Don't update URL while still loading
112115
if (!connectionId || isSchemasLoading) return
113116

@@ -158,6 +161,7 @@ export function useEditorNavigation(connectionId: string): UseEditorNavigationRe
158161
)
159162
}
160163
}, [
164+
enabled,
161165
connectionId,
162166
isSchemasLoading,
163167
isTablesLoading,

src/hooks/useQuery.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,26 +38,26 @@ export const connectionKeys = {
3838
};
3939

4040
// Get schemas for a connection
41-
export function useSchemas(connectionId: string) {
41+
export function useSchemas(connectionId: string, enabled = true) {
4242
return useQuery({
4343
queryKey: queryKeys.schemas(connectionId),
4444
queryFn: async () => {
4545
const response = await queryClient.getSchemas({ connectionId });
4646
return response.schemas;
4747
},
48-
enabled: !!connectionId,
48+
enabled: enabled && !!connectionId,
4949
});
5050
}
5151

5252
// Get tables for a schema
53-
export function useTables(connectionId: string, schema: string) {
53+
export function useTables(connectionId: string, schema: string, enabled = true) {
5454
return useQuery({
5555
queryKey: queryKeys.tables(connectionId, schema),
5656
queryFn: async () => {
5757
const response = await queryClient.getTables({ connectionId, schema });
5858
return response.tables;
5959
},
60-
enabled: !!connectionId && !!schema,
60+
enabled: enabled && !!connectionId && !!schema,
6161
});
6262
}
6363

src/pages/AuditLog.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useNavigate } from 'react-router-dom'
2+
import { ScrollText } from 'lucide-react'
3+
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
4+
import { Label } from '@/components/ui/label'
5+
import { useConnections } from '@/hooks/useQuery'
6+
import { useConnectionPermissions } from '@/hooks/usePermissions'
7+
8+
interface AuditLogProps {
9+
connectionId: string
10+
}
11+
12+
export default function AuditLog({ connectionId }: AuditLogProps) {
13+
const navigate = useNavigate()
14+
const { data: connections, isLoading, error } = useConnections()
15+
const { hasAdmin } = useConnectionPermissions(connectionId)
16+
17+
// Permissions are derived from the connections query, so resolve its loading,
18+
// error, and empty states before gating on hasAdmin — otherwise admins briefly
19+
// see the denied state on load, and errors/empty lists show misleading UI.
20+
const content = error ? (
21+
<p className="text-red-600 text-sm">Failed to load connections.</p>
22+
) : isLoading || !connections ? (
23+
<div className="text-gray-500 text-sm">Loading…</div>
24+
) : connections.length === 0 ? (
25+
<p className="text-gray-600">No connections are configured.</p>
26+
) : !hasAdmin ? (
27+
<p className="text-gray-600">
28+
You need admin permission on this connection to view its audit log.
29+
</p>
30+
) : (
31+
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-gray-300 py-16 text-gray-500">
32+
<ScrollText size={32} />
33+
<p className="text-sm">No audit log entries yet.</p>
34+
</div>
35+
)
36+
37+
return (
38+
<div className="flex-1 bg-white text-gray-900 overflow-auto">
39+
<div className="p-8">
40+
<h1 className="text-3xl font-bold mb-8">Audit Log</h1>
41+
42+
{/* Connection selector stays outside the gate so a user without admin on the
43+
current connection can still switch to one where they do have access. */}
44+
<div className="space-y-2 max-w-xs mb-8">
45+
<Label htmlFor="audit-connection">Connection</Label>
46+
<Select
47+
value={connectionId}
48+
onValueChange={(id) => navigate(`/audit-log?connectionId=${id}`)}
49+
>
50+
<SelectTrigger id="audit-connection">
51+
<SelectValue placeholder="Select connection" />
52+
</SelectTrigger>
53+
<SelectContent>
54+
{connections?.map((conn) => (
55+
<SelectItem key={conn.id} value={conn.id}>
56+
{conn.name}
57+
</SelectItem>
58+
))}
59+
</SelectContent>
60+
</Select>
61+
</div>
62+
63+
{content}
64+
</div>
65+
</div>
66+
)
67+
}

0 commit comments

Comments
 (0)