diff --git a/components/ambient-ui/package-lock.json b/components/ambient-ui/package-lock.json index eb9a98bd0..3d17844fe 100644 --- a/components/ambient-ui/package-lock.json +++ b/components/ambient-ui/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-slot": "^1.2.4", @@ -2271,6 +2272,99 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", diff --git a/components/ambient-ui/package.json b/components/ambient-ui/package.json index 6edaa499e..356234bf9 100644 --- a/components/ambient-ui/package.json +++ b/components/ambient-ui/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-slot": "^1.2.4", diff --git a/components/ambient-ui/src/app/(dashboard)/layout.tsx b/components/ambient-ui/src/app/(dashboard)/layout.tsx index 931daf106..f2159985e 100644 --- a/components/ambient-ui/src/app/(dashboard)/layout.tsx +++ b/components/ambient-ui/src/app/(dashboard)/layout.tsx @@ -3,6 +3,7 @@ import { usePathname } from 'next/navigation' import { AppSidebar } from '@/components/app-sidebar' import { NavHeader } from '@/components/nav-header' +import { StatusBar } from '@/components/status-bar' import { useProject } from '@/queries/use-projects' import { SidebarInset, @@ -38,7 +39,8 @@ export default function DashboardLayout({ projectName={project?.name ?? null} pageName={pageName} /> -
{children}
+
{children}
+ ) diff --git a/components/ambient-ui/src/app/api/ambient/v1/[...path]/route.ts b/components/ambient-ui/src/app/api/ambient/v1/[...path]/route.ts index 741a2a67a..d10af84ec 100644 --- a/components/ambient-ui/src/app/api/ambient/v1/[...path]/route.ts +++ b/components/ambient-ui/src/app/api/ambient/v1/[...path]/route.ts @@ -1,5 +1,5 @@ import { resolveAccessToken, buildProxyHeaders } from "@/lib/auth" -import { API_SERVER_URL } from "@/lib/config" +import { getRuntimeConfig } from "@/lib/runtime-config" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -14,11 +14,22 @@ async function proxyRequest( if (path.some(s => s === ".." || s === ".")) { return Response.json({ error: "invalid_path" }, { status: 400 }) } + + const config = await getRuntimeConfig() + const apiServerUrl = config.apiServerUrl + const pathStr = path.map(s => encodeURIComponent(s)).join("/") - const url = new URL(`/api/ambient/v1/${pathStr}`, API_SERVER_URL) + const url = new URL(`/api/ambient/v1/${pathStr}`, apiServerUrl) url.search = new URL(request.url).search - const accessToken = await resolveAccessToken(request) + // Auth priority: custom token > SSO/oauth token > dev-mode token + let accessToken: string | undefined + if (config.customToken) { + accessToken = config.customToken + } else { + accessToken = await resolveAccessToken(request) + } + if (!accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }) } @@ -28,7 +39,7 @@ async function proxyRequest( // oauth-proxy provides an OpenShift OAuth token (different auth system). // Strip the Authorization header until auth systems are unified — the // BFF's oauth-proxy already authenticates the user. - if (accessToken.startsWith("sha256~")) { + if (!config.customToken && accessToken.startsWith("sha256~")) { delete headers["Authorization"] } diff --git a/components/ambient-ui/src/app/api/config/route.ts b/components/ambient-ui/src/app/api/config/route.ts new file mode 100644 index 000000000..4249cf6dc --- /dev/null +++ b/components/ambient-ui/src/app/api/config/route.ts @@ -0,0 +1,86 @@ +import { getRuntimeConfig, setCustomContext, resetContext } from '@/lib/runtime-config' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const config = await getRuntimeConfig() + return Response.json({ + apiServerUrl: config.apiServerUrl, + customToken: config.customToken !== null, + isCustomContext: config.isCustomContext, + defaultApiServerUrl: config.defaultApiServerUrl, + }) +} + +export async function PUT(request: Request) { + let body: unknown + try { + body = await request.json() + } catch { + return Response.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + if (typeof body !== 'object' || body === null) { + return Response.json( + { error: 'Request body must be a JSON object' }, + { status: 400 }, + ) + } + + const parsed = body as Record + const url = parsed.apiServerUrl + const token = parsed.customToken + + if (url !== undefined && typeof url !== 'string') { + return Response.json( + { error: 'apiServerUrl must be a string' }, + { status: 400 }, + ) + } + + if (token !== undefined && token !== null && typeof token !== 'string') { + return Response.json( + { error: 'customToken must be a string or null' }, + { status: 400 }, + ) + } + + if (typeof url === 'string' && !url.startsWith('http://') && !url.startsWith('https://')) { + return Response.json( + { error: 'apiServerUrl must start with http:// or https://' }, + { status: 400 }, + ) + } + + if (url === undefined && token === undefined) { + return Response.json( + { error: 'Request body must include apiServerUrl or customToken' }, + { status: 400 }, + ) + } + + await setCustomContext( + typeof url === 'string' ? url : undefined, + token === null ? null : typeof token === 'string' ? (token || null) : undefined, + ) + + const config = await getRuntimeConfig() + return Response.json({ + apiServerUrl: config.apiServerUrl, + customToken: config.customToken !== null, + isCustomContext: config.isCustomContext, + defaultApiServerUrl: config.defaultApiServerUrl, + }) +} + +export async function DELETE() { + await resetContext() + const config = await getRuntimeConfig() + return Response.json({ + apiServerUrl: config.apiServerUrl, + customToken: config.customToken !== null, + isCustomContext: config.isCustomContext, + defaultApiServerUrl: config.defaultApiServerUrl, + }) +} diff --git a/components/ambient-ui/src/components/nav-header.tsx b/components/ambient-ui/src/components/nav-header.tsx index 6e3e26a5d..bacc4f8b4 100644 --- a/components/ambient-ui/src/components/nav-header.tsx +++ b/components/ambient-ui/src/components/nav-header.tsx @@ -25,59 +25,52 @@ export function NavHeader({ projectId, projectName, pageName, sessionName }: Nav -
- - - - - - Ambient - - - + + + + + + Ambient + + + - {projectId && ( - <> - - + {projectId && ( + <> + + + + {projectName ?? projectId} + + + + )} + + {pageName && ( + <> + + + {sessionName ? ( - {projectName ?? projectId} + {pageName} - - - )} - - {pageName && ( - <> - - - {sessionName ? ( - - {pageName} - - ) : ( - {pageName} - )} - - - )} - - {sessionName && ( - <> - - - {sessionName} - - - )} - - + ) : ( + {pageName} + )} + + + )} -
- - Connected -
-
+ {sessionName && ( + <> + + + {sessionName} + + + )} + + ) } diff --git a/components/ambient-ui/src/components/status-bar.tsx b/components/ambient-ui/src/components/status-bar.tsx new file mode 100644 index 000000000..0d2454812 --- /dev/null +++ b/components/ambient-ui/src/components/status-bar.tsx @@ -0,0 +1,386 @@ +'use client' + +import { useCallback, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { ChevronDown, Eye, EyeOff, Info, Plug } from 'lucide-react' +import { useConnectionStatus } from '@/hooks/use-connection-status' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' + +type ConfigResponse = { + apiServerUrl: string + customToken: boolean + isCustomContext: boolean + defaultApiServerUrl: string +} + +function useApiServerConfig() { + const queryClient = useQueryClient() + + const { data } = useQuery({ + queryKey: ['config'], + queryFn: async () => { + const response = await fetch('/api/config') + if (!response.ok) { + throw new Error('Failed to fetch config') + } + return response.json() as Promise + }, + staleTime: 60_000, + }) + + const updateContext = useCallback( + async (newUrl?: string, token?: string) => { + const body: Record = {} + if (newUrl) body.apiServerUrl = newUrl + if (token) body.customToken = token + + const response = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!response.ok) { + throw new Error('Failed to update config') + } + queryClient.removeQueries({ queryKey: ['sessions'] }) + queryClient.removeQueries({ queryKey: ['projects'] }) + await queryClient.invalidateQueries({ queryKey: ['config'] }) + await queryClient.invalidateQueries({ queryKey: ['connection-status'] }) + await queryClient.refetchQueries({ queryKey: ['sessions'] }) + await queryClient.refetchQueries({ queryKey: ['projects'] }) + }, + [queryClient], + ) + + const resetContext = useCallback(async () => { + const response = await fetch('/api/config', { method: 'DELETE' }) + if (!response.ok) { + throw new Error('Failed to reset config') + } + queryClient.removeQueries({ queryKey: ['sessions'] }) + queryClient.removeQueries({ queryKey: ['projects'] }) + await queryClient.invalidateQueries({ queryKey: ['config'] }) + await queryClient.invalidateQueries({ queryKey: ['connection-status'] }) + await queryClient.refetchQueries({ queryKey: ['sessions'] }) + await queryClient.refetchQueries({ queryKey: ['projects'] }) + }, [queryClient]) + + return { + apiServerUrl: data?.apiServerUrl ?? '', + isCustomContext: data?.isCustomContext ?? false, + hasCustomToken: data?.customToken ?? false, + updateContext, + resetContext, + } +} + +function StatusDot({ status }: { status: 'connected' | 'disconnected' | 'checking' }) { + if (status === 'connected') { + return ( +