diff --git a/CONTEXT.md b/CONTEXT.md index 1b4287dee..24df99f90 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -66,6 +66,14 @@ _Avoid_: Fetch, load The discovered structure of a connected graph database — vertex types, edge types, their attributes, and how they connect. Populated by Schema Sync when a Connection is first used; not user-defined. _Avoid_: Model, structure +**URL Connection Params**: +Connection details carried in the query string of the `#/connect` route (`graphDbUrl`, `queryEngine`, `awsRegion`, `serviceType`, `name`) that let an external link pre-configure or activate a Connection. Resolved once when the Connect route is entered; the route redirects to the Graph View afterward so the params do not linger in history. +_Avoid_: deep link, connection link, auto-connect + +**URL Connection Intent**: +The action a set of URL Connection Params resolves to against the current Connections: `none` (the params target the already-active Connection — do nothing), `activate` (the params match an inactive Connection — switch to it, replacing the Session), `create` (no match — open the create form pre-filled from the params), or `invalid` (a link carrying a `graphDbUrl` that fails validation — warn and ignore it). A Connection matches only when its `graphDbUrl`, `queryEngine`, and auth posture (IAM on/off, and when on, `awsRegion` and `serviceType`) all agree — auth posture is identity-bearing, so a link requesting different auth than any existing Connection resolves to `create` rather than silently reusing one. +_Avoid_: connection action, deep-link mode + ## Relationships - A **Connection** has exactly one **Query Language** @@ -78,6 +86,8 @@ _Avoid_: Model, structure - **Neighbors** are **Vertices** one hop away from a given **Vertex** - **User Preferences** are scoped per **Vertex Type** and **Edge Type** - The **Graph View**, **Data Table View**, and **Schema View** all render from the same **Session** and **Schema** +- **URL Connection Params** resolve to a **URL Connection Intent** against the current **Connections** and active **Connection** +- Activating a different **Connection** replaces the current **Session** — so an `activate` or `create` **URL Connection Intent** resets the **Session**, while a `none` intent leaves it untouched ## Example dialogue diff --git a/docs/adr/20260612-connection-links.md b/docs/adr/20260612-connection-links.md new file mode 100644 index 000000000..fd3557460 --- /dev/null +++ b/docs/adr/20260612-connection-links.md @@ -0,0 +1,98 @@ +# Connection links via a dedicated `#/connect` route + +Date: 2026-06-12 + +## Status + +Accepted + +## Context + +External applications want to deep-link into Graph Explorer with a connection +already configured — for example, a console that lists Neptune clusters and +offers an "Open in Graph Explorer" link. The link carries the endpoint and auth +details as URL parameters. Graph Explorer stores all connections client-side and +has no server, so the link is the only channel for this hand-off. + +Three decisions in this design are non-obvious and would otherwise invite "why +is it like this?" later. + +## Decisions + +### A dedicated `#/connect` route, not an interstitial gate + +An earlier version mounted a `UrlConnectionGate` component high in the tree that +read `window.location`, froze the resolved intent at mount with `useState`, and +stripped the params via `history.replaceState`. That coupled a reactive +computation to a one-shot lifecycle and put connection-handling logic in the app +shell. + +Connection links are now a first-class route, `#/connect?graphDbUrl=…`. The +route resolves the params once on entry and redirects (router `navigate`, with +`replace`) to the graph canvas on completion. Because Graph Explorer uses a hash +router, the parameters sit **after** the `#` like every other route — third-party +integrators build the link the same way they would any in-app link, and +`window.location.search` (everything before the `#`) is no longer a trap. The +redirect leaves no `#/connect` entry in history, so refresh and back behave +normally without any manual param stripping. + +### Auth posture is part of connection identity + +A link resolves to one of four intents against the existing connections: +`none` (it targets the active connection — do nothing), `activate` (it matches an +inactive connection — switch to it), `create` (no match — open a pre-filled +form), or `invalid` (the link's `graphDbUrl` failed validation — warn and ignore +it). A connection matches only when its `graphDbUrl`, `queryEngine`, **and auth +posture** all agree, where auth posture is IAM on/off and, when on, the region +and service type. + +Auth posture is identity-bearing because activating the wrong-auth connection +would silently connect with credentials the link did not ask for. A link +requesting IAM in `us-east-1` must not reuse a plaintext connection to the same +endpoint, and vice versa. When posture differs, the link falls through to the +`create` form rather than silently reusing a connection. + +### Switching to an existing connection needs no confirmation + +The `activate` path switches connections with no dialog. This matches how the +connections list already works — clicking a connection there activates it on a +single click, resetting the graph session, with no prompt. The link only ever +activates a connection the user already created and validated, so there is +nothing new to confirm. Adding a prompt here would guard an operation the rest +of the app treats as routine. + +The `create` path keeps its friction: the pre-filled form is fully editable and +the user must submit it. This is the deliberate trust gate for the untrusted +endpoint details a link can carry — it is the only path that can introduce a new +database, so it is the only one that asks the user to confirm. + +## Consequences + +- The contract other code and external integrators depend on is the parameter + set (`graphDbUrl`, `queryEngine`, `awsRegion`, `serviceType`, `name`) and the + four-intent model, both in `core/urlConnectionParams.ts`. Parameters are + validated with zod; `graphDbUrl` must be an http(s) URL or the link is ignored. +- A connection from a link always proxies through the same host that serves + Graph Explorer. The proxy base URL is derived from `document.baseURI` rather + than `window.location.origin`, so it keeps the path prefix of path-hosted + deployments (e.g. a Neptune notebook at `/proxy/9250/explorer/` resolves the + proxy to `/proxy/9250`). There is no parameter to target a different proxy + host or to make a direct, non-proxy connection. (When the connection model + drops the explicit proxy `url` in favor of always-relative requests — see + PR #1773 — this derivation goes away and links inherit that behavior.) +- A link can switch to or pre-fill a connection, but it can never create or + connect to a new database without the user submitting the form. The proxy + server's `PROXY_SERVER_ALLOWED_DB_ORIGINS` allowlist remains the unconditional + backstop regardless of what a link requests. See + [security reference](../references/security.md). +- Parameters are plaintext, not an encoded token. This was deliberate: links are + meant to be human-readable and constructible by any integrator. The trust gate + is the create form plus the proxy allowlist, not obscurity. +- Active-connection state is currently global. A separate workstream makes it + per-tab; this route is written against the current global behavior and does + not bake in cross-tab assumptions. + +## User-facing documentation + +[Connections feature → Connection Links](../features/connections.md#connection-links) +documents the parameters, an example link, and the resolved behavior. diff --git a/docs/features/connections.md b/docs/features/connections.md index 5c891db5b..f85debb63 100644 --- a/docs/features/connections.md +++ b/docs/features/connections.md @@ -35,3 +35,45 @@ When a connection is created, Graph Explorer will perform a scan of the graph to ### Data Table Under a listed node type, you can click on the ">" arrow to get to the [Data Table](./data-table.md) view. This allows you to see a sample list of nodes under this type and choose one or more nodes to "Send to Explorer" for getting started quickly if you are new to the data. You can also navigate directly to the Data Table view using the "Data Table" link in the navigation bar. + +## Connection Links + +External applications can link directly to Graph Explorer with a connection pre-configured by opening the `#/connect` route with query parameters. Graph Explorer reads the parameters, then either switches to the matching connection or opens a pre-filled create form for a new one, and redirects to the graph view. + +### Parameters + +| Parameter | Required | Default | Description | +| ------------- | -------- | ----------------------------- | ---------------------------------------------------------------------------------------------- | +| `graphDbUrl` | Yes | — | The graph database endpoint, URL-encoded. | +| `queryEngine` | No | `gremlin` | One of `gremlin`, `openCypher`, or `sparql`. Invalid values fall back to `gremlin`. | +| `awsRegion` | No | — | AWS region for the connection. Providing a region enables IAM auth (SigV4 signed requests). | +| `serviceType` | No | `neptune-db` (when IAM is on) | One of `neptune-db` or `neptune-graph`. Only applies when IAM auth is enabled via `awsRegion`. | +| `name` | No | The endpoint's hostname | Display label for the connection. Defaults to the full hostname of `graphDbUrl`. | + +The parameters belong to the `#/connect` route, so they go _after_ the `#` (Graph Explorer uses hash-based routing). `graphDbUrl` must be URL-encoded. Most languages provide this via `encodeURIComponent()` (JavaScript), `urllib.parse.quote()` (Python), or `URLEncoder.encode()` (Java). + +### Example + +``` +https://[GRAPH_EXPLORER_HOST]/#/connect?graphDbUrl=https%3A%2F%2Fmy-cluster.us-east-1.neptune.amazonaws.com%3A8182&queryEngine=gremlin&awsRegion=us-east-1&serviceType=neptune-db&name=My%20Database +``` + +### Behavior + +When you open a connection link, Graph Explorer does one of the following: + +- **The link matches your active connection** — nothing changes. +- **The link matches a different existing connection** — Graph Explorer switches to it, the same as selecting it in the connections list. No prompt: the connection was already created and validated by you, so there is nothing new to confirm. +- **The link matches no existing connection** — the create-connection form opens, pre-filled with the link's details so you can review or edit any setting before creating it. +- **The link's details are invalid** (for example, a `graphDbUrl` that is not a valid `http`/`https` URL) — the link is ignored and a notification explains what went wrong. + +In all cases Graph Explorer redirects to the graph view once the link is handled, so the `#/connect` URL does not linger in your history and refreshing behaves normally. + +#### What counts as a match + +A link matches an existing connection only when its endpoint, query engine, **and authentication posture** all agree: + +- the same `graphDbUrl` (compared case-insensitively) and the same `queryEngine`, and +- the same auth posture — whether IAM is on (a link enables it by providing `awsRegion`), and when it is on, the same `awsRegion` and `serviceType`. + +Authentication is part of a connection's identity: a link requesting IAM in a region is a _different_ connection from a plaintext one to the same endpoint, and vice versa. A link whose auth posture differs from every existing connection never silently reuses one — it opens the pre-filled create form instead, where you can review the authentication settings before connecting. diff --git a/docs/references/security.md b/docs/references/security.md index d8dc68011..f057f8df2 100644 --- a/docs/references/security.md +++ b/docs/references/security.md @@ -101,6 +101,10 @@ By default, the proxy server forwards requests to any database URL specified by > > This check only applies to requests routed through the proxy server. Connections configured to contact the database directly (bypassing the proxy) are not subject to the allowlist. +> [!NOTE] +> +> [Connection links](../features/connections.md#connection-links) never connect to a new database without your confirmation: a link whose details do not match an existing connection only pre-fills the create form for you to review. A link may switch to a connection you already created, but it cannot create one on your behalf. And in every case the proxy still rejects forwarding to any origin outside `PROXY_SERVER_ALLOWED_DB_ORIGINS`, so a crafted link cannot reach an arbitrary database. + ## HTTP Redirects The proxy server does not follow HTTP redirects from the database. If the database responds with a redirect (3xx status), the proxy returns an error to the client instead of following it. This prevents a compromised or misconfigured database endpoint from redirecting the proxy to an unrelated internal service. diff --git a/packages/graph-explorer/src/App.tsx b/packages/graph-explorer/src/App.tsx index 738a81d43..a8f9b4717 100644 --- a/packages/graph-explorer/src/App.tsx +++ b/packages/graph-explorer/src/App.tsx @@ -1,6 +1,7 @@ import { Route, Routes } from "react-router"; import Redirect from "./components/Redirect"; +import Connect from "./routes/Connect"; import Connections from "./routes/Connections"; import DataExplorer from "./routes/DataExplorer"; import DefaultLayout from "./routes/DefaultLayout"; @@ -16,6 +17,7 @@ export default function App() { return ( }> + } /> } /> } /> } /> diff --git a/packages/graph-explorer/src/core/AppStatusLoader.test.tsx b/packages/graph-explorer/src/core/AppStatusLoader.test.tsx new file mode 100644 index 000000000..0def69383 --- /dev/null +++ b/packages/graph-explorer/src/core/AppStatusLoader.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router"; + +import { TooltipProvider } from "@/components"; +import { getAppStore } from "@/core"; +import { createQueryClient } from "@/core/queryClient"; +import { TestProvider } from "@/utils/testing"; + +import type { + ConfigurationId, + RawConfiguration, +} from "./ConfigurationProvider"; + +const matchingUrl = "https://default-match.neptune.amazonaws.com"; + +const defaultConnection: RawConfiguration = { + id: "Default Connection" as ConfigurationId, + displayLabel: "Default Connection", + connection: { + url: "https://localhost", + queryEngine: "gremlin", + proxyConnection: true, + graphDbUrl: matchingUrl, + }, +}; + +vi.mock("./defaultConnection", () => ({ + fetchDefaultConnection: vi.fn().mockResolvedValue([defaultConnection]), +})); + +function searchFor(graphDbUrl: string, queryEngine = "gremlin") { + return `?graphDbUrl=${encodeURIComponent(graphDbUrl)}&queryEngine=${queryEngine}`; +} + +describe("AppStatusLoader URL params + default connection", () => { + test("does not prompt to create when a loading default connection matches the connect URL", async () => { + const AppStatusLoader = (await import("./AppStatusLoader")).default; + const Connect = (await import("@/routes/Connect")).default; + const store = getAppStore(); + const queryClient = createQueryClient(); + + // Enter the connect route before the default connection has loaded. The + // loader gates the route behind a spinner until the default arrives, so the + // route must resolve against the loaded default (a no-op) rather than + // prompting to create a duplicate. + render( + + + + + + } /> + graph canvas} + /> + + + + + , + ); + + // Once the default connection loads, the route is a no-op and redirects to + // the graph canvas. + await waitFor(() => { + expect(screen.getByText("graph canvas")).toBeInTheDocument(); + }); + + // The URL targets the connection the default provides, so we must NOT + // see a create-connection prompt. + expect( + screen.queryByText("Create connection from link"), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Add Connection" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/graph-explorer/src/core/AppStatusLoader.tsx b/packages/graph-explorer/src/core/AppStatusLoader.tsx index 36535788f..727f716d3 100644 --- a/packages/graph-explorer/src/core/AppStatusLoader.tsx +++ b/packages/graph-explorer/src/core/AppStatusLoader.tsx @@ -75,7 +75,6 @@ function LoadDefaultConfig({ children }: PropsWithChildren) { ); } - // Loading from config file if exists if ( configuration.size === 0 && defaultConnectionConfigs && diff --git a/packages/graph-explorer/src/core/StateProvider/useActivateConnection.test.ts b/packages/graph-explorer/src/core/StateProvider/useActivateConnection.test.ts new file mode 100644 index 000000000..8c25e86b3 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/useActivateConnection.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +import { act } from "@testing-library/react"; + +import { configurationAtom } from "@/core"; +import { getAppStore } from "@/core/StateProvider/appStore"; +import { + createTestableVertex, + DbState, + renderHookWithState, +} from "@/utils/testing"; +import { createRandomRawConfiguration } from "@/utils/testing/randomData"; + +import { nodesAtom } from "./nodes"; +import { activeConfigurationAtom } from "./storageAtoms"; +import useActivateConnection from "./useActivateConnection"; + +describe("useActivateConnection", () => { + test("sets the active connection and resets the graph session", () => { + const state = new DbState(); + const vertex = createTestableVertex(); + state.addTestableVertexToGraph(vertex); + + const other = createRandomRawConfiguration(); + + const { result } = renderHookWithState( + () => useActivateConnection(), + state, + ); + const store = getAppStore(); + store.set(configurationAtom, prev => { + const updated = new Map(prev); + updated.set(other.id, other); + return updated; + }); + + expect(store.get(nodesAtom).size).toBeGreaterThan(0); + + act(() => result.current(other.id)); + + expect(store.get(activeConfigurationAtom)).toBe(other.id); + expect(store.get(nodesAtom).size).toBe(0); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/useActivateConnection.ts b/packages/graph-explorer/src/core/StateProvider/useActivateConnection.ts new file mode 100644 index 000000000..0dc77ad21 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/useActivateConnection.ts @@ -0,0 +1,28 @@ +import { useAtomCallback } from "jotai/utils"; +import { useCallback } from "react"; + +import type { ConfigurationId } from "@/core"; + +import { logger } from "@/utils"; + +import { activeConfigurationAtom } from "./storageAtoms"; +import useResetState from "./useResetState"; + +/** + * Returns a callback that activates a connection and resets the graph session, + * the same behavior as manually switching connections. Use this whenever the + * active connection changes to a different connection. + */ +export default function useActivateConnection() { + const resetState = useResetState(); + return useAtomCallback( + useCallback( + (_get, set, configId: ConfigurationId) => { + logger.debug("Setting active connection to", configId); + set(activeConfigurationAtom, configId); + resetState(); + }, + [resetState], + ), + ); +} diff --git a/packages/graph-explorer/src/core/urlConnectionParams.test.ts b/packages/graph-explorer/src/core/urlConnectionParams.test.ts new file mode 100644 index 000000000..c6d414e9d --- /dev/null +++ b/packages/graph-explorer/src/core/urlConnectionParams.test.ts @@ -0,0 +1,527 @@ +import type { + ConfigurationId, + RawConfiguration, +} from "./ConfigurationProvider"; + +import { + parseUrlConnectionParams, + findMatchingConnection, + buildConnectionFromParams, + deriveProxyBaseUrl, + resolveUrlConnectionIntent, + type UrlConnectionParams, +} from "./urlConnectionParams"; + +describe("parseUrlConnectionParams", () => { + test("returns null when graphDbUrl is missing", () => { + expect(parseUrlConnectionParams("")).toBeNull(); + expect(parseUrlConnectionParams("?queryEngine=openCypher")).toBeNull(); + }); + + test("parses graphDbUrl with defaults", () => { + const result = parseUrlConnectionParams( + "?graphDbUrl=https%3A%2F%2Fg-xxx.us-west-2.neptune-graph.amazonaws.com", + ); + expect(result).toEqual({ + graphDbUrl: "https://g-xxx.us-west-2.neptune-graph.amazonaws.com", + queryEngine: "gremlin", + awsRegion: "", + serviceType: undefined, + name: "g-xxx.us-west-2.neptune-graph.amazonaws.com", + }); + }); + + test("derives the name from the full hostname (without the port) when name is absent", () => { + const result = parseUrlConnectionParams( + "?graphDbUrl=https%3A%2F%2Fmy-cluster.us-east-1.neptune.amazonaws.com%3A8182", + ); + expect(result?.name).toBe("my-cluster.us-east-1.neptune.amazonaws.com"); + }); + + test("returns null when graphDbUrl is not a valid URL", () => { + expect(parseUrlConnectionParams("?graphDbUrl=not-a-url")).toBeNull(); + }); + + test("returns null when graphDbUrl is not an http(s) URL", () => { + expect( + parseUrlConnectionParams( + "?graphDbUrl=javascript%3Aalert(1)&queryEngine=gremlin", + ), + ).toBeNull(); + expect( + parseUrlConnectionParams( + "?graphDbUrl=ftp%3A%2F%2Fexample.com&queryEngine=gremlin", + ), + ).toBeNull(); + }); + + test("parses all parameters", () => { + const result = parseUrlConnectionParams( + "?graphDbUrl=https%3A%2F%2Fg-xxx.neptune-graph.amazonaws.com&queryEngine=openCypher&awsRegion=us-west-2&serviceType=neptune-graph&name=My+Graph", + ); + expect(result).toEqual({ + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + queryEngine: "openCypher", + awsRegion: "us-west-2", + serviceType: "neptune-graph", + name: "My Graph", + }); + }); + + test("falls back to gremlin when queryEngine is invalid", () => { + const result = parseUrlConnectionParams( + "?graphDbUrl=https%3A%2F%2Fg-xxx.neptune-graph.amazonaws.com&queryEngine=sql", + ); + expect(result?.queryEngine).toBe("gremlin"); + }); + + test("drops invalid serviceType", () => { + const result = parseUrlConnectionParams( + "?graphDbUrl=https%3A%2F%2Fg-xxx.neptune-graph.amazonaws.com&serviceType=bogus", + ); + expect(result?.serviceType).toBeUndefined(); + }); +}); + +describe("findMatchingConnection", () => { + const configs = new Map([ + [ + "conn-1" as ConfigurationId, + { + id: "conn-1" as ConfigurationId, + displayLabel: "Test", + connection: { + url: "https://localhost", + queryEngine: "openCypher", + graphDbUrl: "https://g-abc.us-west-2.neptune-graph.amazonaws.com", + }, + }, + ], + [ + "conn-2" as ConfigurationId, + { + id: "conn-2" as ConfigurationId, + displayLabel: "Gremlin DB", + connection: { + url: "https://localhost", + queryEngine: "gremlin", + graphDbUrl: "https://my-cluster.neptune.amazonaws.com", + }, + }, + ], + ]); + + test("finds match by graphDbUrl and queryEngine", () => { + const match = findMatchingConnection(configs, { + graphDbUrl: "https://g-abc.us-west-2.neptune-graph.amazonaws.com", + queryEngine: "openCypher", + awsRegion: "", + serviceType: undefined, + name: "", + }); + expect(match?.id).toBe("conn-1"); + }); + + test("matches case-insensitively on graphDbUrl", () => { + const match = findMatchingConnection(configs, { + graphDbUrl: "https://G-ABC.US-WEST-2.NEPTUNE-GRAPH.AMAZONAWS.COM", + queryEngine: "openCypher", + awsRegion: "", + serviceType: undefined, + name: "", + }); + expect(match?.id).toBe("conn-1"); + }); + + test("returns null when queryEngine differs", () => { + const match = findMatchingConnection(configs, { + graphDbUrl: "https://g-abc.us-west-2.neptune-graph.amazonaws.com", + queryEngine: "gremlin", + awsRegion: "", + serviceType: undefined, + name: "", + }); + expect(match).toBeNull(); + }); + + test("returns null when no match", () => { + const match = findMatchingConnection(configs, { + graphDbUrl: "https://unknown.neptune.amazonaws.com", + queryEngine: "gremlin", + awsRegion: "", + serviceType: undefined, + name: "", + }); + expect(match).toBeNull(); + }); + + test("prefers the active connection when multiple match", () => { + const duplicateUrl = "https://dupe.neptune.amazonaws.com"; + const dupes = new Map([ + [ + "dupe-1" as ConfigurationId, + { + id: "dupe-1" as ConfigurationId, + displayLabel: "First", + connection: { + url: "https://localhost", + queryEngine: "gremlin", + graphDbUrl: duplicateUrl, + }, + }, + ], + [ + "dupe-2" as ConfigurationId, + { + id: "dupe-2" as ConfigurationId, + displayLabel: "Second", + connection: { + url: "https://localhost", + queryEngine: "gremlin", + graphDbUrl: duplicateUrl, + }, + }, + ], + ]); + + const match = findMatchingConnection( + dupes, + { + graphDbUrl: duplicateUrl, + queryEngine: "gremlin", + awsRegion: "", + serviceType: undefined, + name: "", + }, + "dupe-2" as ConfigurationId, + ); + expect(match?.id).toBe("dupe-2"); + }); + + describe("auth posture is part of connection identity", () => { + const url = "https://iam.neptune.amazonaws.com"; + + const iamConfig = ( + id: string, + auth: { + awsAuthEnabled?: boolean; + awsRegion?: string; + serviceType?: "neptune-db" | "neptune-graph"; + }, + ): [ConfigurationId, RawConfiguration] => [ + id as ConfigurationId, + { + id: id as ConfigurationId, + displayLabel: id, + connection: { + url: "https://localhost", + queryEngine: "gremlin", + graphDbUrl: url, + ...auth, + }, + }, + ]; + + const paramsWith = (auth: { + awsRegion?: string; + serviceType?: "neptune-db" | "neptune-graph"; + }): UrlConnectionParams => ({ + graphDbUrl: url, + queryEngine: "gremlin", + awsRegion: auth.awsRegion ?? "", + serviceType: auth.serviceType, + name: "", + }); + + test("an IAM link does not match a non-IAM connection", () => { + const configs = new Map([iamConfig("plain", {})]); + const match = findMatchingConnection( + configs, + paramsWith({ awsRegion: "us-east-1" }), + ); + expect(match).toBeNull(); + }); + + test("a non-IAM link does not match an IAM connection", () => { + const configs = new Map([ + iamConfig("iam", { + awsAuthEnabled: true, + awsRegion: "us-east-1", + serviceType: "neptune-db", + }), + ]); + const match = findMatchingConnection(configs, paramsWith({})); + expect(match).toBeNull(); + }); + + test("IAM links with different regions do not match", () => { + const configs = new Map([ + iamConfig("west", { + awsAuthEnabled: true, + awsRegion: "us-west-2", + serviceType: "neptune-db", + }), + ]); + const match = findMatchingConnection( + configs, + paramsWith({ awsRegion: "us-east-1" }), + ); + expect(match).toBeNull(); + }); + + test("IAM links with different service types do not match", () => { + const configs = new Map([ + iamConfig("db", { + awsAuthEnabled: true, + awsRegion: "us-east-1", + serviceType: "neptune-db", + }), + ]); + const match = findMatchingConnection( + configs, + paramsWith({ awsRegion: "us-east-1", serviceType: "neptune-graph" }), + ); + expect(match).toBeNull(); + }); + + test("matches an IAM connection with the same region and service type", () => { + const configs = new Map([ + iamConfig("match", { + awsAuthEnabled: true, + awsRegion: "us-east-1", + serviceType: "neptune-db", + }), + ]); + const match = findMatchingConnection( + configs, + paramsWith({ awsRegion: "us-east-1", serviceType: "neptune-db" }), + ); + expect(match?.id).toBe("match"); + }); + + test("a link without a service type matches an IAM connection on the default service type", () => { + const configs = new Map([ + iamConfig("default", { + awsAuthEnabled: true, + awsRegion: "us-east-1", + serviceType: "neptune-db", + }), + ]); + const match = findMatchingConnection( + configs, + paramsWith({ awsRegion: "us-east-1" }), + ); + expect(match?.id).toBe("default"); + }); + }); + + test("tiebreaks by name when multiple match and none is active", () => { + const duplicateUrl = "https://dupe.neptune.amazonaws.com"; + const dupes = new Map([ + [ + "dupe-1" as ConfigurationId, + { + id: "dupe-1" as ConfigurationId, + displayLabel: "First", + connection: { + url: "https://localhost", + queryEngine: "gremlin", + graphDbUrl: duplicateUrl, + }, + }, + ], + [ + "dupe-2" as ConfigurationId, + { + id: "dupe-2" as ConfigurationId, + displayLabel: "Production", + connection: { + url: "https://localhost", + queryEngine: "gremlin", + graphDbUrl: duplicateUrl, + }, + }, + ], + ]); + + const match = findMatchingConnection(dupes, { + graphDbUrl: duplicateUrl, + queryEngine: "gremlin", + awsRegion: "", + serviceType: undefined, + name: "Production", + }); + expect(match?.id).toBe("dupe-2"); + }); +}); + +describe("buildConnectionFromParams", () => { + test("builds connection with IAM enabled", () => { + const connection = buildConnectionFromParams( + { + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + queryEngine: "openCypher", + awsRegion: "us-west-2", + serviceType: "neptune-graph", + name: "My Graph", + }, + "https://localhost", + ); + + expect(connection.displayLabel).toBe("My Graph"); + expect(connection.connection).toEqual({ + url: "https://localhost", + queryEngine: "openCypher", + proxyConnection: true, + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + awsAuthEnabled: true, + awsRegion: "us-west-2", + serviceType: "neptune-graph", + }); + }); + + test("builds connection with IAM disabled when no region is given", () => { + const connection = buildConnectionFromParams( + { + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + queryEngine: "gremlin", + awsRegion: "", + serviceType: undefined, + name: "No IAM", + }, + "https://localhost", + ); + + expect(connection.connection?.awsAuthEnabled).toBe(false); + expect(connection.connection?.serviceType).toBeUndefined(); + }); + + test("enables IAM with a default service type when only region is given", () => { + const connection = buildConnectionFromParams( + { + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + queryEngine: "gremlin", + awsRegion: "us-west-2", + serviceType: undefined, + name: "Region Only", + }, + "https://localhost", + ); + + expect(connection.connection?.awsAuthEnabled).toBe(true); + expect(connection.connection?.awsRegion).toBe("us-west-2"); + expect(connection.connection?.serviceType).toBe("neptune-db"); + }); + + test("generates a unique id per call", () => { + const params = { + graphDbUrl: "https://g-xxx.neptune-graph.amazonaws.com", + queryEngine: "openCypher" as const, + awsRegion: "", + serviceType: undefined, + name: "A", + }; + const a = buildConnectionFromParams(params, "https://localhost"); + const b = buildConnectionFromParams(params, "https://localhost"); + expect(a.id).not.toBe(b.id); + }); +}); + +describe("deriveProxyBaseUrl", () => { + test("climbs one level from a path-hosted notebook deployment", () => { + expect( + deriveProxyBaseUrl( + "https://my-notebook.notebook.us-west-2.sagemaker.aws/proxy/9250/explorer/", + ), + ).toBe("https://my-notebook.notebook.us-west-2.sagemaker.aws/proxy/9250"); + }); + + test("resolves to the origin for a root-hosted deployment", () => { + expect(deriveProxyBaseUrl("https://localhost:5173/explorer/")).toBe( + "https://localhost:5173", + ); + }); +}); + +describe("resolveUrlConnectionIntent", () => { + const activeUrl = "https://active.neptune.amazonaws.com"; + const activeId = "active-conn" as ConfigurationId; + const configs = new Map([ + [ + activeId, + { + id: activeId, + displayLabel: "Active", + connection: { + url: "https://localhost", + queryEngine: "gremlin", + graphDbUrl: activeUrl, + }, + }, + ], + ]); + + const paramsFor = (graphDbUrl: string): UrlConnectionParams => ({ + graphDbUrl, + queryEngine: "gremlin", + awsRegion: "", + serviceType: undefined, + name: "Whatever", + }); + + test("is a no-op when the URL matches the active connection", () => { + const intent = resolveUrlConnectionIntent( + paramsFor(activeUrl), + configs, + activeId, + "https://localhost", + ); + expect(intent).toEqual({ kind: "none" }); + }); + + test("activates a matching connection that is not active", () => { + const inactiveId = "inactive-conn" as ConfigurationId; + const inactiveUrl = "https://inactive.neptune.amazonaws.com"; + const withInactive = new Map(configs); + withInactive.set(inactiveId, { + id: inactiveId, + displayLabel: "Inactive", + connection: { + url: "https://localhost", + queryEngine: "gremlin", + graphDbUrl: inactiveUrl, + }, + }); + + const intent = resolveUrlConnectionIntent( + paramsFor(inactiveUrl), + withInactive, + activeId, + "https://localhost", + ); + expect(intent).toEqual({ + kind: "activate", + connection: withInactive.get(inactiveId), + }); + }); + + test("creates rather than reusing the active connection when the link requests a different auth posture", () => { + const intent = resolveUrlConnectionIntent( + { ...paramsFor(activeUrl), awsRegion: "us-east-1" }, + configs, + activeId, + "https://localhost", + ); + expect(intent.kind).toBe("create"); + }); + + test("creates a new connection when nothing matches", () => { + const intent = resolveUrlConnectionIntent( + paramsFor("https://brand-new.neptune.amazonaws.com"), + configs, + activeId, + "https://localhost", + ); + expect(intent.kind).toBe("create"); + }); +}); diff --git a/packages/graph-explorer/src/core/urlConnectionParams.ts b/packages/graph-explorer/src/core/urlConnectionParams.ts new file mode 100644 index 000000000..c4a9db1d6 --- /dev/null +++ b/packages/graph-explorer/src/core/urlConnectionParams.ts @@ -0,0 +1,236 @@ +import { + type ConnectionConfig, + type NeptuneServiceType, + neptuneServiceTypeOptions, + queryEngineOptions, +} from "@shared/types"; +import { z } from "zod"; + +import { DEFAULT_SERVICE_TYPE } from "@/utils"; + +import { + type ConfigurationId, + createNewConfigurationId, + type RawConfiguration, +} from "./ConfigurationProvider"; + +const UrlConnectionParamsSchema = z.object({ + // Only http(s) endpoints are meaningful, and constraining the scheme keeps a + // crafted link from seeding the form with something like `javascript:` — + // defense in depth on top of the editable create form and proxy allowlist. + graphDbUrl: z.url({ protocol: /^https?$/ }), + queryEngine: z.enum(queryEngineOptions).catch("gremlin"), + awsRegion: z.string().default(""), + serviceType: z.enum(neptuneServiceTypeOptions).optional().catch(undefined), + name: z.string().optional(), +}); + +export type UrlConnectionParams = z.infer & { + name: string; +}; + +/** + * Whether the search string carries a connection link at all (a `graphDbUrl` is + * present, valid or not). Lets callers tell "this isn't a connection link" apart + * from "this is a connection link with invalid data", which `parseUrlConnectionParams` + * collapses into a single `null`. + */ +export function hasConnectionLinkParams(search: string): boolean { + return Boolean(new URLSearchParams(search).get("graphDbUrl")); +} + +/** + * Parse URL search params into connection params. Returns null when there is no + * `graphDbUrl`, or when it is not a valid http(s) URL. + */ +export function parseUrlConnectionParams( + search: string, +): UrlConnectionParams | null { + const params = new URLSearchParams(search); + const graphDbUrl = params.get("graphDbUrl"); + if (!graphDbUrl) return null; + + const parsed = UrlConnectionParamsSchema.safeParse({ + graphDbUrl, + queryEngine: params.get("queryEngine") ?? undefined, + awsRegion: params.get("awsRegion") ?? undefined, + serviceType: params.get("serviceType") ?? undefined, + name: params.get("name") ?? undefined, + }); + if (!parsed.success) return null; + + return { + ...parsed.data, + name: parsed.data.name ?? deriveNameFromUrl(graphDbUrl), + }; +} + +/** + * Derives a connection name from a database URL by taking the full hostname + * (without scheme or port), which the user can recognize at a glance. The URL + * is already validated as http(s) by the time this runs, so `new URL` is safe. + */ +function deriveNameFromUrl(graphDbUrl: string): string { + const { hostname } = new URL(graphDbUrl); + return hostname || graphDbUrl; +} + +/** + * The auth posture a connection link or existing connection carries. This is + * part of a connection's identity for matching: a link requesting IAM in a + * given region/service type is a *different* connection from a plaintext one to + * the same endpoint, so it must not silently reuse it. When IAM is off, region + * and service type are not meaningful and are normalized away. + */ +type AuthPosture = { + awsAuthEnabled: boolean; + awsRegion: string; + serviceType: NeptuneServiceType | undefined; +}; + +/** The auth posture a connection link's params resolve to. */ +function authPostureFromParams(params: UrlConnectionParams): AuthPosture { + const awsAuthEnabled = Boolean(params.awsRegion); + return { + awsAuthEnabled, + awsRegion: awsAuthEnabled ? params.awsRegion : "", + serviceType: awsAuthEnabled + ? (params.serviceType ?? DEFAULT_SERVICE_TYPE) + : undefined, + }; +} + +/** The auth posture an existing connection carries. */ +function authPostureFromConnection(connection: ConnectionConfig): AuthPosture { + const awsAuthEnabled = Boolean(connection.awsAuthEnabled); + return { + awsAuthEnabled, + awsRegion: awsAuthEnabled ? (connection.awsRegion ?? "") : "", + serviceType: awsAuthEnabled + ? (connection.serviceType ?? DEFAULT_SERVICE_TYPE) + : undefined, + }; +} + +function authPosturesMatch(a: AuthPosture, b: AuthPosture): boolean { + return ( + a.awsAuthEnabled === b.awsAuthEnabled && + a.awsRegion === b.awsRegion && + a.serviceType === b.serviceType + ); +} + +/** + * Find an existing connection matching the link's identity: graphDbUrl + * (case-insensitive) + queryEngine + auth posture (IAM on/off, region, and + * service type). Auth posture is identity-bearing so a link requesting IAM + * never silently reuses a plaintext connection to the same endpoint (or vice + * versa) — a mismatch falls through to the editable create form instead. + * + * When several connections match, resolve in priority order: the active + * connection (so a URL targeting it is a no-op), then a connection whose label + * matches the `name` param, then the first match found. + */ +export function findMatchingConnection( + configurations: Map, + params: UrlConnectionParams, + activeId: ConfigurationId | null = null, +): RawConfiguration | null { + const linkAuthPosture = authPostureFromParams(params); + const matches = configurations + .values() + .filter( + config => + config.connection?.graphDbUrl?.toLowerCase() === + params.graphDbUrl.toLowerCase() && + config.connection?.queryEngine === params.queryEngine && + authPosturesMatch( + authPostureFromConnection(config.connection), + linkAuthPosture, + ), + ) + .toArray(); + + if (matches.length === 0) { + return null; + } + + const activeMatch = matches.find(config => config.id === activeId); + const nameMatch = matches.find(config => config.displayLabel === params.name); + + return activeMatch ?? nameMatch ?? matches[0]; +} + +/** + * The base URL of the proxy server that connection links target, derived from + * the document base URI. The UI is served one level below the proxy API (static + * files at `/explorer/`, API routes at `/`), so climbing one level + * recovers the proxy root for every deployment: `https://host` for a root-hosted + * app and `https://host/proxy/9250` for a path-hosted Neptune notebook. + */ +export function deriveProxyBaseUrl(baseURI: string): string { + return new URL("..", baseURI).href.replace(/\/$/, ""); +} + +/** + * Build a RawConfiguration from URL params. IAM auth is enabled whenever a + * region is provided, defaulting the service type rather than silently leaving + * auth off when only a region is given. + */ +export function buildConnectionFromParams( + params: UrlConnectionParams, + proxyBaseUrl: string, +): RawConfiguration { + const { awsAuthEnabled, awsRegion, serviceType } = + authPostureFromParams(params); + return { + id: createNewConfigurationId(), + displayLabel: params.name, + connection: { + url: proxyBaseUrl, + queryEngine: params.queryEngine, + proxyConnection: true, + graphDbUrl: params.graphDbUrl, + awsAuthEnabled, + awsRegion, + serviceType, + }, + }; +} + +/** + * The action a set of URL connection params resolves to, given the current + * connections. Callers dispatch on `kind` rather than juggling match/pending + * booleans. + */ +export type UrlConnectionIntent = + | { kind: "none" } + | { kind: "invalid" } + | { kind: "activate"; connection: RawConfiguration } + | { kind: "create"; connection: RawConfiguration }; + +/** + * Resolve URL params into a single intent: + * - matches the active connection → `none` (nothing to do) + * - matches an inactive connection → `activate` it + * - no match → `create` a new connection seeded from the params + */ +export function resolveUrlConnectionIntent( + params: UrlConnectionParams, + configurations: Map, + activeId: ConfigurationId | null, + proxyBaseUrl: string, +): UrlConnectionIntent { + const match = findMatchingConnection(configurations, params, activeId); + + if (match) { + return match.id === activeId + ? { kind: "none" } + : { kind: "activate", connection: match }; + } + + return { + kind: "create", + connection: buildConnectionFromParams(params, proxyBaseUrl), + }; +} diff --git a/packages/graph-explorer/src/core/useUrlConnectionIntent.test.tsx b/packages/graph-explorer/src/core/useUrlConnectionIntent.test.tsx new file mode 100644 index 000000000..0f174e862 --- /dev/null +++ b/packages/graph-explorer/src/core/useUrlConnectionIntent.test.tsx @@ -0,0 +1,62 @@ +import type { PropsWithChildren } from "react"; + +// @vitest-environment happy-dom +import { renderHook } from "@testing-library/react"; +import { Provider } from "jotai"; +import { MemoryRouter } from "react-router"; + +import { getAppStore } from "@/core"; +import { DbState } from "@/utils/testing"; + +import { useUrlConnectionIntent } from "./useUrlConnectionIntent"; + +function searchFor(graphDbUrl: string, queryEngine = "gremlin") { + return `?graphDbUrl=${encodeURIComponent(graphDbUrl)}&queryEngine=${queryEngine}`; +} + +function renderIntent(search: string, state = new DbState()) { + const store = getAppStore(); + state.applyTo(store); + return renderHook(() => useUrlConnectionIntent(), { + wrapper: ({ children }: PropsWithChildren) => ( + + + {children} + + + ), + }); +} + +describe("useUrlConnectionIntent", () => { + test("is a no-op when there are no URL params", () => { + const { result } = renderIntent(""); + expect(result.current).toEqual({ kind: "none" }); + }); + + test("is a no-op when the URL matches the active connection", () => { + const state = new DbState(); + const activeUrl = "https://active.neptune.amazonaws.com"; + state.activeConfig.connection = { + url: "https://localhost", + queryEngine: "gremlin", + proxyConnection: true, + graphDbUrl: activeUrl, + }; + + const { result } = renderIntent(searchFor(activeUrl), state); + expect(result.current).toEqual({ kind: "none" }); + }); + + test("creates a new connection when nothing matches", () => { + const { result } = renderIntent( + searchFor("https://brand-new.neptune.amazonaws.com"), + ); + expect(result.current.kind).toBe("create"); + }); + + test("is invalid when a connection link carries a malformed graphDbUrl", () => { + const { result } = renderIntent("?graphDbUrl=not-a-url"); + expect(result.current).toEqual({ kind: "invalid" }); + }); +}); diff --git a/packages/graph-explorer/src/core/useUrlConnectionIntent.ts b/packages/graph-explorer/src/core/useUrlConnectionIntent.ts new file mode 100644 index 000000000..6ce2e180a --- /dev/null +++ b/packages/graph-explorer/src/core/useUrlConnectionIntent.ts @@ -0,0 +1,40 @@ +import { useAtomValue } from "jotai"; +import { useLocation } from "react-router"; + +import { activeConfigurationAtom, configurationAtom } from "./StateProvider"; +import { + deriveProxyBaseUrl, + hasConnectionLinkParams, + parseUrlConnectionParams, + resolveUrlConnectionIntent, + type UrlConnectionIntent, +} from "./urlConnectionParams"; + +/** + * Resolves the current route's connection params into an intent against the + * live connection state. Reads the router location so the params are taken from + * after the `#` (Graph Explorer uses a hash router); `window.location.search` + * would be empty here. Drive it from a router context (a real route or a + * `MemoryRouter` in tests). + */ +export function useUrlConnectionIntent(): UrlConnectionIntent { + const location = useLocation(); + const configuration = useAtomValue(configurationAtom); + const activeId = useAtomValue(activeConfigurationAtom); + + const params = parseUrlConnectionParams(location.search); + if (!params) { + // A link carrying a graphDbUrl that failed validation is a malformed link, + // not the absence of one — surface it so the user knows their link was bad. + return hasConnectionLinkParams(location.search) + ? { kind: "invalid" } + : { kind: "none" }; + } + + return resolveUrlConnectionIntent( + params, + configuration, + activeId, + deriveProxyBaseUrl(document.baseURI), + ); +} diff --git a/packages/graph-explorer/src/modules/AvailableConnections/ConnectionRow.tsx b/packages/graph-explorer/src/modules/AvailableConnections/ConnectionRow.tsx index 2b9fa1909..919239427 100644 --- a/packages/graph-explorer/src/modules/AvailableConnections/ConnectionRow.tsx +++ b/packages/graph-explorer/src/modules/AvailableConnections/ConnectionRow.tsx @@ -1,16 +1,10 @@ -import { useAtomCallback } from "jotai/utils"; import { DatabaseIcon } from "lucide-react"; -import { useCallback } from "react"; + +import type { RawConfiguration } from "@/core"; import { ListRowContent, ListRowSubtitle, ListRowTitle } from "@/components"; -import { - activeConfigurationAtom, - type ConfigurationId, - type RawConfiguration, -} from "@/core"; -import useResetState from "@/core/StateProvider/useResetState"; +import useActivateConnection from "@/core/StateProvider/useActivateConnection"; import { useTranslations } from "@/hooks"; -import { logger } from "@/utils"; function ConnectionRow({ connection, @@ -22,7 +16,8 @@ function ConnectionRow({ isDisabled: boolean; }) { const t = useTranslations(); - const setActiveConfig = useSetActiveConfigCallback(connection.id); + const activateConnection = useActivateConnection(); + const setActiveConfig = () => activateConnection(connection.id); const dbUrl = connection.connection ? connection.connection.proxyConnection @@ -61,18 +56,4 @@ function ConnectionRow({ ); } -function useSetActiveConfigCallback(configId: ConfigurationId) { - const resetState = useResetState(); - return useAtomCallback( - useCallback( - (_get, set) => { - logger.debug("Setting active connection to", configId); - set(activeConfigurationAtom, configId); - resetState(); - }, - [configId, resetState], - ), - ); -} - export { ConnectionRow }; diff --git a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.test.tsx b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.test.tsx new file mode 100644 index 000000000..177976def --- /dev/null +++ b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom +import { render, screen } from "@testing-library/react"; + +import { TooltipProvider } from "@/components"; +import { type ConfigurationId, getAppStore } from "@/core"; +import { createQueryClient } from "@/core/queryClient"; +import { DbState, TestProvider } from "@/utils/testing"; + +import CreateConnection, { mapToConnectionForm } from "./CreateConnection"; + +function renderCreateConnection(ui: React.ReactElement) { + const state = new DbState(); + const store = getAppStore(); + state.applyTo(store); + const queryClient = createQueryClient(); + + return render(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + }); +} + +describe("CreateConnection", () => { + test("prefills the form from initialValues without entering edit mode", () => { + renderCreateConnection( + {}} + />, + ); + + expect(screen.getByLabelText("Name")).toHaveValue("Seeded Graph"); + expect(screen.getByLabelText("Graph Connection URL")).toHaveValue( + "https://seed.neptune.amazonaws.com", + ); + // Still in "add" mode, not "update" + expect( + screen.getByRole("button", { name: "Add Connection" }), + ).toBeInTheDocument(); + }); +}); + +describe("mapToConnectionForm", () => { + test("maps a connection's IAM auth into form values", () => { + const form = mapToConnectionForm({ + id: "url-https://g.example.com-openCypher" as ConfigurationId, + displayLabel: "My Graph", + connection: { + url: "https://localhost", + queryEngine: "openCypher", + proxyConnection: true, + graphDbUrl: "https://g.example.com", + awsAuthEnabled: true, + awsRegion: "us-west-2", + serviceType: "neptune-graph", + }, + }); + + expect(form).toMatchObject({ + name: "My Graph", + queryEngine: "openCypher", + proxyConnection: true, + graphDbUrl: "https://g.example.com", + awsAuthEnabled: true, + awsRegion: "us-west-2", + serviceType: "neptune-graph", + }); + }); + + test("returns undefined when given no config", () => { + expect(mapToConnectionForm(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx index 637a0dc26..c6e9b1036 100644 --- a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx +++ b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx @@ -60,6 +60,12 @@ const CONNECTIONS_OP: { export type CreateConnectionProps = { existingConfig?: ConfigurationContextProps; + /** + * Seeds a new connection form with prefilled, fully editable values. Unlike + * `existingConfig`, this stays in "add" mode and does not run the + * meaningful-change reset logic. + */ + initialValues?: Partial; onClose(): void; }; @@ -79,8 +85,8 @@ function mapToConnection(data: Required): ConnectionConfig { }; } -function mapToConnectionForm( - existingConfig: ConfigurationContextProps | undefined, +export function mapToConnectionForm( + existingConfig: RawConfiguration | undefined, ) { if (!existingConfig) { return; @@ -99,12 +105,13 @@ function mapToConnectionForm( const CreateConnection = ({ existingConfig, + initialValues, onClose, }: CreateConnectionProps) => { const queryClient = useQueryClient(); const configId = existingConfig?.id; - const initialData = mapToConnectionForm(existingConfig); + const initialData = mapToConnectionForm(existingConfig) ?? initialValues; const onSave = useAtomCallback( useCallback( diff --git a/packages/graph-explorer/src/modules/CreateConnection/index.ts b/packages/graph-explorer/src/modules/CreateConnection/index.ts index 9f9a56dc4..96131030f 100644 --- a/packages/graph-explorer/src/modules/CreateConnection/index.ts +++ b/packages/graph-explorer/src/modules/CreateConnection/index.ts @@ -1 +1 @@ -export { default } from "./CreateConnection"; +export { default, mapToConnectionForm } from "./CreateConnection"; diff --git a/packages/graph-explorer/src/routes/Connect/Connect.test.tsx b/packages/graph-explorer/src/routes/Connect/Connect.test.tsx new file mode 100644 index 000000000..47710a0c6 --- /dev/null +++ b/packages/graph-explorer/src/routes/Connect/Connect.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router"; +import { toast } from "sonner"; + +import { TooltipProvider } from "@/components"; +import { type ConfigurationId, getAppStore } from "@/core"; +import { createQueryClient } from "@/core/queryClient"; +import { + activeConfigurationAtom, + configurationAtom, +} from "@/core/StateProvider"; +import { DbState, TestProvider } from "@/utils/testing"; + +import Connect from "./Connect"; + +function LocationDisplay() { + const location = useLocation(); + return ( +
{location.pathname + location.search}
+ ); +} + +function searchFor(graphDbUrl: string, queryEngine = "gremlin") { + return `?graphDbUrl=${encodeURIComponent(graphDbUrl)}&queryEngine=${queryEngine}`; +} + +function renderConnect(search: string) { + const store = getAppStore(); + const queryClient = createQueryClient(); + render( + + + + + } /> + graph canvas} /> + + + + + , + ); + return store; +} + +function seedInactiveConnection(store: ReturnType) { + const inactiveUrl = "https://inactive.neptune.amazonaws.com"; + const inactiveConfig = { + id: "inactive-conn" as ConfigurationId, + displayLabel: "Inactive", + connection: { + url: "https://localhost", + queryEngine: "gremlin" as const, + proxyConnection: true, + graphDbUrl: inactiveUrl, + }, + }; + store.set(configurationAtom, prev => { + const updated = new Map(prev); + updated.set(inactiveConfig.id, inactiveConfig); + return updated; + }); + return { inactiveUrl, inactiveConfig }; +} + +describe("Connect route", () => { + test("redirects to the graph canvas when there are no params", () => { + new DbState().applyTo(getAppStore()); + + renderConnect(""); + + expect(screen.getByTestId("location")).toHaveTextContent("/graph-explorer"); + expect(screen.getByText("graph canvas")).toBeInTheDocument(); + }); + + test("redirects to the graph canvas when the params target the active connection", () => { + const state = new DbState(); + const activeUrl = "https://active.neptune.amazonaws.com"; + state.activeConfig.connection = { + url: "https://localhost", + queryEngine: "gremlin", + proxyConnection: true, + graphDbUrl: activeUrl, + }; + state.applyTo(getAppStore()); + + renderConnect(searchFor(activeUrl)); + + expect(screen.getByTestId("location")).toHaveTextContent("/graph-explorer"); + }); + + test("activates an inactive matching connection and redirects without a prompt", async () => { + new DbState().applyTo(getAppStore()); + const store = getAppStore(); + const { inactiveUrl, inactiveConfig } = seedInactiveConnection(store); + + renderConnect(searchFor(inactiveUrl)); + + // Switching to an already-created connection is the same no-confirm + // operation as clicking it in the connections list, so there is no dialog. + await waitFor(() => { + expect(store.get(activeConfigurationAtom)).toBe(inactiveConfig.id); + }); + expect(screen.getByTestId("location")).toHaveTextContent("/graph-explorer"); + }); + + test("opens the create form prefilled when nothing matches", () => { + new DbState().applyTo(getAppStore()); + + renderConnect( + `${searchFor("https://brand-new.neptune.amazonaws.com")}&name=Brand+New`, + ); + + expect( + screen.getByRole("button", { name: "Add Connection" }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Name")).toHaveValue("Brand New"); + // The dialog explains the connection details came from the user's link + expect(screen.getByText(/details from your link/i)).toBeInTheDocument(); + }); + + test("warns and redirects when the link's data is invalid", async () => { + new DbState().applyTo(getAppStore()); + + renderConnect("?graphDbUrl=not-a-url"); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "Invalid connection link", + expect.objectContaining({ description: expect.any(String) }), + ); + }); + expect(screen.getByTestId("location")).toHaveTextContent("/graph-explorer"); + expect( + screen.queryByRole("button", { name: "Add Connection" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/graph-explorer/src/routes/Connect/Connect.tsx b/packages/graph-explorer/src/routes/Connect/Connect.tsx new file mode 100644 index 000000000..bc4634cfb --- /dev/null +++ b/packages/graph-explorer/src/routes/Connect/Connect.tsx @@ -0,0 +1,110 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; + +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components"; +import useActivateConnection from "@/core/StateProvider/useActivateConnection"; +import { useUrlConnectionIntent } from "@/core/useUrlConnectionIntent"; +import CreateConnection, { + mapToConnectionForm, +} from "@/modules/CreateConnection"; +import { logger } from "@/utils"; + +const GRAPH_CANVAS_ROUTE = "/graph-explorer"; + +/** + * Route that opens a connection from link params (`#/connect?graphDbUrl=…`). It + * resolves the params against the current connections and either redirects + * straight to the graph canvas (the params target the active connection or are + * absent), warns and redirects when the link's data is invalid, silently + * switches to a matching existing connection, or opens the create-connection + * form prefilled from the params. Every outcome ends at the graph canvas, so + * the connect URL never lingers in history. + * + * Switching to an existing connection needs no confirmation: it is the same + * no-prompt operation as clicking that connection in the connections list, and + * it only ever targets a connection the user already created and validated. The + * create form keeps its friction — that is the trust gate for the untrusted + * endpoint details a link can carry. + */ +export default function Connect() { + const navigate = useNavigate(); + const intent = useUrlConnectionIntent(); + const activateConnection = useActivateConnection(); + + // Every intent except `create` ends by leaving for the canvas. Activating a + // connection and warning about a bad link are side effects that happen + // because the link was opened (not from any in-app gesture), so they belong + // in an effect — and pairing each with the redirect in the same effect + // guarantees the toast is raised before we navigate away, rather than racing + // a render-phase redirect. (`create` redirects from its own dialog button.) + const connectionIdToActivate = + intent.kind === "activate" ? intent.connection.id : null; + const isInvalid = intent.kind === "invalid"; + const isCreate = intent.kind === "create"; + useEffect(() => { + if (isCreate) { + return; + } + if (connectionIdToActivate) { + logger.debug( + "Activating matching connection from URL params", + connectionIdToActivate, + ); + activateConnection(connectionIdToActivate); + } else if (isInvalid) { + logger.debug("Ignoring connection link with invalid params"); + toast.error("Invalid connection link", { + // A stable id dedupes the toast if the effect runs more than once. + id: "invalid-connection-link", + description: + "The link's connection details were invalid, so it was ignored. Check the graph database URL and try again.", + }); + } + navigate(GRAPH_CANVAS_ROUTE, { replace: true }); + }, [ + isCreate, + connectionIdToActivate, + isInvalid, + activateConnection, + navigate, + ]); + + if (isCreate) { + return ( + + !open && navigate(GRAPH_CANVAS_ROUTE, { replace: true }) + } + > + + + Create connection from link + + Review the connection details from your link and create it to + continue. + + + + navigate(GRAPH_CANVAS_ROUTE, { replace: true })} + /> + + + + ); + } + + // none / invalid / activate: the effect above handles the side effect and + // the redirect, so there is nothing to render in the meantime. + return null; +} diff --git a/packages/graph-explorer/src/routes/Connect/index.ts b/packages/graph-explorer/src/routes/Connect/index.ts new file mode 100644 index 000000000..fffd1a3c7 --- /dev/null +++ b/packages/graph-explorer/src/routes/Connect/index.ts @@ -0,0 +1 @@ +export { default } from "./Connect"; diff --git a/vitest.config.ts b/vitest.config.ts index 4704ae090..fe4ff2781 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,10 +6,10 @@ export default defineConfig({ coverage: { thresholds: { autoUpdate: (newThreshold: number) => Math.floor(newThreshold), - statements: 65, - branches: 45, + statements: 66, + branches: 46, functions: 58, - lines: 73, + lines: 74, }, }, },