diff --git a/GLOSSARY.md b/GLOSSARY.md index ca4e7be4c..f4382fa17 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -10,9 +10,11 @@ A React-based web application that lets users visually explore graph databases w **Graph Database**: The external graph database a user connects to and explores — the source of all vertices and edges, reached over HTTP via a Connection. It is the user's own data, brought along and queried live; distinct from the local app state (connections, schema cache, styles, sessions) that Graph Explorer keeps in the browser's IndexedDB, plus the per-tab View Layout in sessionStorage. _Avoid_: Database (ambiguous — clarify remote graph database vs. local persisted state) -**Connection**: A saved database profile — the **Database URL**, query language, and optional IAM authentication settings. The client reaches the database through the same-origin **Proxy Server**, so no proxy endpoint is configured, unless the Connection is a **Direct Connection**. Users create and manage these in the UI. _Avoid_: Configuration (legacy term being phased out — previously bundled connection + schema + Styles into one object; `Configuration`-prefixed code names like `RawConfiguration` or `ConfigurationId` were renamed to their `Connection`/`SavedConnection` forms, see epic #2296); proxy endpoint (removed — see ADR `unify-docker-image-remove-sagemaker-variant`) +**Connection**: A saved database profile — the **Database URL**, query language, and optional IAM authentication settings. Each Connection is either a **Proxy Connection** (the default) or a **Direct Connection**, and IAM authentication is only available on a Proxy Connection. The Proxy Server is same-origin, so no proxy endpoint is configured. Users create and manage these in the UI. _Avoid_: Configuration (legacy term being phased out — previously bundled connection + schema + Styles into one object; `Configuration`-prefixed code names like `RawConfiguration` or `ConfigurationId` were renamed to their `Connection`/`SavedConnection` forms, see epic #2296); proxy endpoint (removed — see ADR `unify-docker-image-remove-sagemaker-variant`) -**Direct Connection**: A Connection whose requests the browser sends to the **Database URL** itself instead of through the **Proxy Server**. The database must allow cross-origin requests from the Graph Explorer page, and Proxy Server capabilities such as IAM signing don't apply. A supported option for databases that only the browser can reach or that allow CORS. See ADR `unify-docker-image-remove-sagemaker-variant`. _Avoid_: public endpoint, non-proxy connection +**Proxy Connection**: A Connection whose requests go through the same-origin **Proxy Server**, which reaches the **Database URL** on the browser's behalf. The default, and the only kind that can use IAM authentication, because only the Proxy Server can sign a request. _Avoid_: server connection + +**Direct Connection**: A Connection whose requests the browser sends to the **Database URL** itself instead of through the **Proxy Server**, so the database must allow cross-origin requests from the Graph Explorer page. A supported option for databases that only the browser can reach or that already allow CORS. _Avoid_: public endpoint, non-proxy connection, browser connection **Database URL**: The endpoint of a Connection's Graph Database, stored as `graphDbUrl`. The **Proxy Server** sends database requests here, so it must be reachable from the host running Graph Explorer, not from the browser, except for a **Direct Connection**. _Avoid_: Graph Connection URL, graph DB URL diff --git a/docs/adr/20260612-connection-links.md b/docs/adr/20260612-connection-links.md index 7c779f403..22d4e57ed 100644 --- a/docs/adr/20260612-connection-links.md +++ b/docs/adr/20260612-connection-links.md @@ -51,7 +51,7 @@ The form renders in place inside the app shell rather than as a portaled modal. ## Consequences - The contract other code and external integrators depend on is the parameter set (`graphDbUrl`, `queryEngine`, `awsRegion`, `serviceType`, `name`) and the three-intent model, both in `core/connectionLink`. Parameters are validated with zod: an absent optional param takes its default, while an explicit unsupported value rejects the link, so a link never connects with settings it did not ask for. `queryEngine`'s default is not fixed: it resolves to `openCypher` when `serviceType` is `neptune-graph` and to `gremlin` otherwise, and an explicit `queryEngine` is kept whatever the `serviceType`. `neptune-graph` also requires `awsRegion`, since Neptune Analytics only accepts IAM-signed requests and a region is what turns IAM on. `graphDbUrl` also cannot contain a backslash. URL parsing reads one as a slash, so `https://evil.tld\@prod.neptune.amazonaws.com` would resolve to `evil.tld` while the create form shows what looks like a Neptune host. -- A link never proposes a **Direct Connection**; there is no parameter for one. The user can still opt into one through the checkbox in the pre-filled form, as in any create form. +- A link never proposes a **Direct Connection**; there is no parameter for one. The user can still opt into one through the "Connection method" choice in the pre-filled form, as in any create form. - 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. Unless the user opts into a Direct Connection there, connections a link creates route through the **Proxy Server**, so `PROXY_SERVER_ALLOWED_DB_ORIGINS` also bounds what a link can reach when that variable is set. It is unset by default. A link without IAM can still match an existing **Direct Connection** to the same URL, whose requests bypass the proxy as they always do; a link requesting IAM never matches one, because a Direct Connection cannot sign. 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. - The active connection is scoped per tab (see [Per-tab Active Connection ADR](20260618-per-tab-active-connection.md)): it lives in that tab's `sessionStorage`, seeded at cold start from a shared, last-writer-wins breadcrumb. A link resolves and activates against the tab it opens in, so it never changes what another open tab is viewing. diff --git a/docs/adr/20260616-unify-docker-image-remove-sagemaker-variant.md b/docs/adr/20260616-unify-docker-image-remove-sagemaker-variant.md index afedb4ea3..073ec0e31 100644 --- a/docs/adr/20260616-unify-docker-image-remove-sagemaker-variant.md +++ b/docs/adr/20260616-unify-docker-image-remove-sagemaker-variant.md @@ -18,7 +18,7 @@ Eliminate the separate SageMaker image by: 2. **Resolving API routes from the document's own path** — the client builds API routes (sparql, gremlin, openCypher, defaultConnection, etc.) with `apiUrl()`, which cuts the last occurrence of the static mount segment (`STATIC_MOUNT_PATH`, `/explorer`) out of `location.pathname` and joins the endpoint onto what is left, against `location.origin`. The server mounts static files at `/explorer` and API routes at `/`, so removing that segment lands on the API root at any external prefix. A path that has no mount segment at all means a reverse proxy renamed it away, and that throws `ReverseProxyMisconfiguredError` rather than guessing. `fetchDatabaseRequest()` resolves database routes with `apiUrl()` for every connection except a direct one (see decision 3). -3. **Routing through the proxy by default, with direct connections as a supported option** — remove `url` (proxy endpoint) from the connection model. The client sends requests to the same-origin proxy server, and the connection config simplifies to: database endpoint, query engine, and optional IAM settings. Direct connections stay available, marked `proxyConnection: false`, where the browser calls `graphDbUrl` itself without the proxy-only headers. IAM authentication belongs to the proxy route, since only the proxy can sign a request. +3. **Routing through the proxy by default, with direct connections as a supported option** — remove `url` (proxy endpoint) from the connection model. The client sends requests to the same-origin proxy server, and the connection config simplifies to: database endpoint, query engine, and optional IAM settings. Direct connections stay available, marked `proxyConnection: false`, where the browser calls `graphDbUrl` itself without the proxy-only headers. The connection form presents both as a "Connection method" choice, "Via proxy server" (the default) or "Directly via browser", and offers IAM authentication only on the proxy route, since only the proxy can sign a request. 4. **Moving SageMaker defaults to runtime** — `process-environment.sh` reads `NEPTUNE_NOTEBOOK=true` at container startup and writes port/log-style/SSL settings to `.env`. The Dockerfile no longer sets these, allowing the app's built-in defaults (port 80, default log style) to apply when the variable is absent. @@ -31,9 +31,9 @@ Eliminate the separate SageMaker image by: - **Derive the proxy endpoint automatically but keep the field.** Removes the configuration burden without removing the concept, leaving a vestigial field in the Connection model and in every exported file. The exported connection file still writes `url`, but only as a write-only compatibility field for older importers (see Consequences), not as part of the model. - **Move the database endpoint into server configuration entirely, so the browser never names a database URL.** This would remove the need for `PROXY_SERVER_ALLOWED_DB_ORIGINS`, but it contradicts the client-owns-its-connections model described in `docs/agents/product.md`, and it is a much larger change. -Deprecating direct connections and removing them in a follow-up was the closest alternative. The relative-URL work alone unifies the image, so removal was possible, but it costs users the two things only the direct route offers. A restricted network can let the browser reach a database that the server cannot. A public, CORS-permissive endpoint works from the browser with no server involved. The price of keeping the route is two request paths and the feature gates that give the direct path fewer capabilities than the proxy path. +Deprecating direct connections and removing them in a follow-up was the closest alternative. The relative-URL work alone unifies the image, so removal was possible, but it costs users the two things only the direct route offers. A restricted network can let the browser reach a database that the server cannot. A public, CORS-permissive endpoint works from the browser with no server involved. The price of keeping the route is two request paths and the feature gates that give the direct path fewer capabilities than the proxy path. That price is paid once, in one place, and the form names the main gaps on the "Directly via browser" choice. -Amazon Neptune sends no CORS headers, so a browser could never reach Neptune directly. A maintainer confirms this on issue [#244](https://github.com/aws/graph-explorer/issues/244): the proxy server is required for accessing Neptune, even with local VPC access. That is why the proxy is the default. The direct path still works against public, CORS-permissive SPARQL endpoints, and that was a deliberate investment. See issue [#530](https://github.com/aws/graph-explorer/issues/530) with PR [#529](https://github.com/aws/graph-explorer/pull/529), and issue [#393](https://github.com/aws/graph-explorer/issues/393). A container with internet access can route those endpoints through the proxy, but nothing requires users to. +Amazon Neptune sends no CORS headers, so a browser could never reach Neptune directly. A maintainer confirms this on issue [#244](https://github.com/aws/graph-explorer/issues/244): the proxy server is required for accessing Neptune, even with local VPC access. That is why the proxy is the default and why the proxy card says it works with Amazon Neptune. The direct path still works against public, CORS-permissive SPARQL endpoints, and that was a deliberate investment. See issue [#530](https://github.com/aws/graph-explorer/issues/530) with PR [#529](https://github.com/aws/graph-explorer/pull/529), and issue [#393](https://github.com/aws/graph-explorer/issues/393). A container with internet access can route those endpoints through the proxy, but nothing requires users to. The direct path does not offer IAM authentication, query cancellation, server-side logging, or `PROXY_SERVER_ALLOWED_DB_ORIGINS` enforcement. Issue [#1599](https://github.com/aws/graph-explorer/issues/1599) treats the direct path firing unintentionally as a bug. The removal tracked by issue [#1618](https://github.com/aws/graph-explorer/issues/1618), with [#1622](https://github.com/aws/graph-explorer/issues/1622), [#1625](https://github.com/aws/graph-explorer/issues/1625), and [#539](https://github.com/aws/graph-explorer/issues/539), is no longer planned. @@ -43,7 +43,7 @@ The direct path does not offer IAM authentication, query cancellation, server-si - One image to build, test, scan, and publish — halves CI time for Docker. - Users no longer need to figure out or configure the proxy server URL. -- The connection form simplifies to the database endpoint and auth settings, plus a direct option. +- The connection form simplifies to the database endpoint, a "Connection method" choice, and IAM settings that appear only on the proxy route. - Deployments behind arbitrary reverse proxies (not just Jupyter) work without build-time configuration. - Removes ~20 lines of conditional Dockerfile logic and the two-path defaultConnection fallback hack in the client. diff --git a/docs/features/connections.md b/docs/features/connections.md index fb50676d2..71c39d63b 100644 --- a/docs/features/connections.md +++ b/docs/features/connections.md @@ -11,15 +11,17 @@ For guides on connecting to specific databases, see [Connecting to databases](.. - **Name:** Enter a name for your connection (e.g., `MyNeptuneCluster`). - **Database URL:** Provide the endpoint URL for your graph database (e.g., `https://[NEPTUNE_ENDPOINT]:8182`). Ensure that the URL does not end with `/`. - **Query Language:** Choose a query language that corresponds to your graph database. -- **Use AWS IAM authentication:** Check this box if connecting to Amazon Neptune using IAM Auth and SigV4 signed requests. Checking it reveals the **AWS Region** and **Service Type** fields. +- **Connection method:** Choose how requests reach the database. + - **Via proxy server** (default): the Graph Explorer server sends requests to your database. This works with Amazon Neptune, supports IAM authentication, query cancellation, and server-side logging, and needs no CORS setup on the database. + - **Directly via browser:** your browser sends requests to the database itself. The database must allow cross-origin requests (CORS) from the Graph Explorer page, and IAM authentication, query cancellation, and server-side logging aren't available. When Graph Explorer is served over HTTPS, the browser usually blocks an `http://` Database URL unless it points at a loopback host such as `localhost`; see [Insecure Database URL](../guides/troubleshooting.md#insecure-database-url). Use it for databases that only your browser can reach, or that already allow CORS. +- **Use AWS IAM authentication:** Available with **Via proxy server**. Check this box if connecting to Amazon Neptune using IAM Auth and SigV4 signed requests. The Graph Explorer server signs requests with its own AWS credentials, not yours. Checking it reveals the **AWS Region** and **Service Type** fields. - **AWS Region:** Specify the AWS region where the Neptune cluster is hosted (e.g., us-east-1). - **Service Type:** Choose the service type: **Neptune DB** or **Neptune Analytics**. -The next three settings are grouped under an **Advanced options** section that you expand to reach. It starts expanded when the connection you are editing already overrides one of them, so an existing override is never hidden from you. +The next two settings are grouped under an **Advanced options** section that you expand to reach. It starts expanded when the connection you are editing already overrides one of them, so an existing override is never hidden from you. - **Fetch Timeout:** Check **Enable Fetch Timeout** to reveal **Fetch Timeout (ms)**, then specify the timeout for the fetch request. - **Neighbor Expansion Limit:** Check **Override Default Neighbor Expansion Limit** to reveal this field, then specify the default limit for neighbor expansion. This will override the app setting for neighbor expansion. -- **Connect directly from the browser:** Check this box to have your browser send requests to the database itself instead of through the Graph Explorer server. The database must allow cross-origin requests (CORS) from the Graph Explorer page, and IAM authentication isn't available, so the IAM fields are hidden. When Graph Explorer is served over HTTPS, the browser usually blocks an `http://` Database URL unless it points at a loopback host such as `localhost`. See [Insecure Database URL](../guides/troubleshooting.md#insecure-database-url). ## Available Connections @@ -93,7 +95,7 @@ A link matches an existing connection only when its Database URL, query language - the same `graphDbUrl` (normalized and compared case-insensitively, so a trailing slash or stray whitespace on either side doesn't prevent a match) 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`. -A direct connection (**Connect directly from the browser**) never uses IAM, so a link with `awsRegion` never matches one, while a link without it can. Its requests then go from your browser to the database as they always do, not through the Graph Explorer server. +A connection that connects **Directly via browser** never uses IAM, so a link with `awsRegion` never matches one, while a link without it can. Its requests then go from your browser to the database as they always do, not through the Graph Explorer server. 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 Database URL, 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/guides/connecting-to-neptune.md b/docs/guides/connecting-to-neptune.md index bd43feeb4..a6d7c485a 100644 --- a/docs/guides/connecting-to-neptune.md +++ b/docs/guides/connecting-to-neptune.md @@ -9,6 +9,7 @@ Graph Explorer connects to Amazon Neptune through its proxy server, which forwar - Name: `My Neptune Cluster` - Database URL: `https://{your-cluster-endpoint}:8182` - Query Language: Choose the query language for your graph +- Connection method: **Via proxy server**. Amazon Neptune sends no CORS headers, so a browser can't reach it directly. - Use AWS IAM authentication: checked if IAM authentication is enabled on your cluster. Checking it reveals the AWS Region and Service Type fields. - AWS Region: your cluster's region (e.g., `us-east-1`) - Service Type: **Neptune DB** (or **Neptune Analytics**) diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 44ed2fe0e..e538a1321 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -138,19 +138,19 @@ For a proxied connection, the server, not the browser, must have network access ### Database Not Reachable From the Browser -"Database not reachable from the browser" appears only for a connection with **Connect directly from the browser** checked. The browser sends that connection's requests to the database itself, and the request failed before any response arrived. Common causes: +"Database not reachable from the browser" appears only for a connection with **Connection method** set to **Directly via browser**. The browser sends that connection's requests to the database itself, and the request failed before any response arrived. Common causes: - The database doesn't allow cross-origin requests (CORS) from the Graph Explorer page's origin. - The database isn't running, or the Database URL has the wrong host or port. - The browser's network can't reach the database. -To fix it, either configure the database to allow cross-origin requests from the Graph Explorer page, or edit the connection and uncheck **Connect directly from the browser** under **Advanced options** so the Graph Explorer server connects to the database instead. +To fix it, either configure the database to allow cross-origin requests from the Graph Explorer page, or edit the connection and set **Connection method** to **Via proxy server** so the Graph Explorer server connects to the database instead. ### Insecure Database URL -"Insecure database URL" appears only for a connection with **Connect directly from the browser** checked, when the Graph Explorer page is served over HTTPS, the Database URL starts with `http://`, and the request failed. Browsers block requests from an HTTPS page to an HTTP address as mixed content, so the browser most likely blocked this one. A loopback Database URL is exempt, because browsers allow it over HTTP: `localhost`, any `.localhost` subdomain, any `127.x.x.x` address, or `[::1]`. +"Insecure database URL" appears only for a connection with **Connection method** set to **Directly via browser**, when the Graph Explorer page is served over HTTPS, the Database URL starts with `http://`, and the request failed. Browsers block requests from an HTTPS page to an HTTP address as mixed content, so the browser most likely blocked this one. A loopback Database URL is exempt, because browsers allow it over HTTP: `localhost`, any `.localhost` subdomain, any `127.x.x.x` address, or `[::1]`. -To fix it, either use an `https://` Database URL, or edit the connection and uncheck **Connect directly from the browser** under **Advanced options** so the Graph Explorer server connects to the database instead. +To fix it, either use an `https://` Database URL, or edit the connection and set **Connection method** to **Via proxy server** so the Graph Explorer server connects to the database instead. If your browser allows insecure content for this site, the request wasn't blocked and failed for another reason. Check that the database is running at the Database URL and allows cross-origin requests (CORS) from the Graph Explorer page, as described in [Database Not Reachable From the Browser](#database-not-reachable-from-the-browser). diff --git a/packages/graph-explorer/src/components/RadioGroup.tsx b/packages/graph-explorer/src/components/RadioGroup.tsx new file mode 100644 index 000000000..058d3f647 --- /dev/null +++ b/packages/graph-explorer/src/components/RadioGroup.tsx @@ -0,0 +1,42 @@ +import { RadioGroup as RadioGroupPrimitive } from "radix-ui"; +import * as React from "react"; + +import { cn } from "@/utils"; + +function RadioGroup({ + className, + ...props +}: React.ComponentPropsWithRef) { + return ( + + ); +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentPropsWithRef) { + return ( + + + + + + ); +} + +export { RadioGroup, RadioGroupItem }; diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index 479091848..9e84e143b 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -67,6 +67,7 @@ export * from "./PersistenceStatusIndicator/PersistenceStatusIndicator"; export * from "./Toaster"; +export * from "./RadioGroup"; export * from "./RouteButton"; export * from "./SchemaDiscoveryBoundary"; export * from "./Spinner"; diff --git a/packages/graph-explorer/src/modules/CreateConnection/ConnectionMethodField.tsx b/packages/graph-explorer/src/modules/CreateConnection/ConnectionMethodField.tsx new file mode 100644 index 000000000..db92806e5 --- /dev/null +++ b/packages/graph-explorer/src/modules/CreateConnection/ConnectionMethodField.tsx @@ -0,0 +1,185 @@ +import { useId } from "react"; +import { z } from "zod"; + +import { + Checkbox, + FormItem, + InputField, + Label, + RadioGroup, + RadioGroupItem, + SelectField, +} from "@/components"; +import { cn } from "@/utils"; + +import type { + ConnectionFormValues, + SetConnectionFormField, +} from "./connectionFormModel"; + +import { serviceTypeSchema } from "./connectionFormModel"; + +const connectionMethodSchema = z.enum(["proxy", "browser"]); +type ConnectionMethod = z.infer; + +type ConnectionMethodFieldProps = IamSettingsProps & + Pick; + +/** + * Picks how requests reach the database. IAM authentication is offered only on + * the proxy method, since the browser can't sign a request. + */ +export function ConnectionMethodField({ + directConnection, + setField, + ...iamSettings +}: ConnectionMethodFieldProps) { + const labelId = useId(); + const method: ConnectionMethod = directConnection ? "browser" : "proxy"; + + return ( + + + + setField("directConnection")( + connectionMethodSchema.parse(value) === "browser", + ) + } + className="gap-3" + > + } + /> + + + + ); +} + +function MethodCard({ + value, + selected, + title, + description, + footer, +}: { + value: ConnectionMethod; + selected: boolean; + title: string; + description: string; + footer?: React.ReactNode; +}) { + const id = useId(); + const titleId = `${id}-title`; + const descriptionId = `${id}-description`; + + return ( +
+ {/* The label's pseudo-element stretches over the card so any part of it + selects the method, while the footer stays above to remain usable. */} + + {selected && footer &&
{footer}
} +
+ ); +} + +type IamSettingsProps = Pick< + ConnectionFormValues, + "awsAuthEnabled" | "awsRegion" | "serviceType" +> & { + regionError: string | undefined; + setField: SetConnectionFormField; +}; + +function IamSettings({ + awsAuthEnabled, + awsRegion, + serviceType, + regionError, + setField, +}: IamSettingsProps) { + return ( +
+ + {awsAuthEnabled && ( + <> +

+ The Graph Explorer server signs requests with its own AWS + credentials, not yours. +

+ + + + + + + + setField("serviceType")(serviceTypeSchema.parse(value)) + } + /> + + + )} +
+ ); +} diff --git a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.test.tsx b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.test.tsx index 0cde13e4d..fd3baea57 100644 --- a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.test.tsx +++ b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.test.tsx @@ -123,27 +123,104 @@ describe("CreateConnection", () => { expect(savedConnection.connection).not.toHaveProperty("proxyConnection"); }); - describe("direct connection", () => { - const directOption = { - name: /Connect directly from the browser/, - }; + describe("connection method", () => { + const proxyOption = { name: "Via proxy server" }; + const browserOption = { name: "Directly via browser" }; + const iamOption = { name: "Use AWS IAM authentication" }; + + function renderEditing(connection: ConnectionConfig) { + const config = { ...createRandomSavedConnection(), connection }; + const store = renderCreateConnection( + , + ); + store.set(configurationAtom, new Map([[config.id, config]])); + return { store, config }; + } + + test("connects through the proxy server by default", () => { + renderCreateConnection(); + + expect(screen.getByRole("radio", proxyOption)).toBeChecked(); + expect(screen.getByRole("radio", browserOption)).not.toBeChecked(); + }); - test("hides the IAM controls when connecting directly", async () => { + test("moves between the methods with the arrow keys", async () => { const user = userEvent.setup(); renderCreateConnection(); - await user.click( - screen.getByRole("checkbox", { name: "Use AWS IAM authentication" }), - ); - await openAdvancedOptions(user); - await user.click(screen.getByRole("checkbox", directOption)); + await user.click(screen.getByRole("radio", proxyOption)); + // Radix moves focus on a timer, so the key is held until the focus lands + await user.keyboard("{ArrowDown>}"); + await waitFor(() => { + expect(screen.getByRole("radio", browserOption)).toBeChecked(); + }); + await user.keyboard("{/ArrowDown}"); + }); + + test("says the server signs requests with its own credentials", async () => { + const user = userEvent.setup(); + renderCreateConnection(); + + expect(screen.queryByText(/with its own AWS credentials/)).toBeNull(); + + await user.click(screen.getByRole("checkbox", iamOption)); expect( - screen.queryByRole("checkbox", { name: "Use AWS IAM authentication" }), - ).toBeNull(); + screen.getByText(/signs requests with its own AWS credentials/), + ).toBeInTheDocument(); + }); + + test("hides the IAM controls when connecting directly via the browser", async () => { + const user = userEvent.setup(); + renderCreateConnection(); + + await user.click(screen.getByRole("checkbox", iamOption)); + await user.click(screen.getByRole("radio", browserOption)); + + expect(screen.queryByRole("checkbox", iamOption)).toBeNull(); expect(screen.queryByRole("textbox", { name: "AWS Region" })).toBeNull(); }); + test("restores the IAM settings when switching back to the proxy server", async () => { + const user = userEvent.setup(); + renderCreateConnection(); + + await user.click(screen.getByRole("checkbox", iamOption)); + await user.type( + screen.getByRole("textbox", { name: "AWS Region" }), + "us-west-2", + ); + await user.click(screen.getByRole("radio", browserOption)); + await user.click(screen.getByRole("radio", proxyOption)); + + expect(screen.getByRole("checkbox", iamOption)).toBeChecked(); + expect(screen.getByRole("textbox", { name: "AWS Region" })).toHaveValue( + "us-west-2", + ); + }); + + test("selects a method by clicking its description", async () => { + const user = userEvent.setup(); + renderCreateConnection(); + + await user.click( + screen.getByText( + "Your browser reaches the database itself, so the database must allow CORS from this page. No AWS IAM authentication, query cancellation or server-side logging.", + ), + ); + + expect(screen.getByRole("radio", browserOption)).toBeChecked(); + }); + test("saves a direct connection without IAM settings", async () => { const user = userEvent.setup(); const store = renderCreateConnection( @@ -158,13 +235,10 @@ describe("CreateConnection", () => { screen.getByRole("textbox", { name: "Database URL" }), "https://database.example.com:8182", ); - // IAM set up before switching to direct must not be saved, since the - // region it requires is hidden and a direct request is never signed. - await user.click( - screen.getByRole("checkbox", { name: "Use AWS IAM authentication" }), - ); - await openAdvancedOptions(user); - await user.click(screen.getByRole("checkbox", directOption)); + // IAM set up before switching to the browser must not be saved, since + // the region it requires is hidden and a direct request is never signed. + await user.click(screen.getByRole("checkbox", iamOption)); + await user.click(screen.getByRole("radio", browserOption)); await user.click(screen.getByRole("button", { name: "Add Connection" })); await waitFor(() => { @@ -197,8 +271,7 @@ describe("CreateConnection", () => { screen.getByRole("textbox", { name: "Database URL" }), graphDbUrl, ); - await openAdvancedOptions(user); - await user.click(screen.getByRole("checkbox", directOption)); + await user.click(screen.getByRole("radio", browserOption)); await user.click( screen.getByRole("button", { name: "Add Connection" }), ); @@ -206,7 +279,7 @@ describe("CreateConnection", () => { expect(store.get(configurationAtom)).toHaveLength(0); expect( screen.getByText( - "A direct connection needs a full URL starting with http:// or https://", + "Directly via browser needs a full URL starting with http:// or https://", ), ).toBeInTheDocument(); }, @@ -233,53 +306,25 @@ describe("CreateConnection", () => { }); }); - test("shows an existing direct connection as direct", () => { - const config = { - ...createRandomSavedConnection(), - connection: { - graphDbUrl: "https://database.example.com:8182", - proxyConnection: false, - }, - }; - - renderCreateConnection( - , - ); + test("shows an existing direct connection as Directly via browser", () => { + renderEditing({ + graphDbUrl: "https://database.example.com:8182", + proxyConnection: false, + }); - expect(screen.getByRole("checkbox", directOption)).toBeChecked(); + expect(screen.getByRole("radio", browserOption)).toBeChecked(); + expect( + screen.getByRole("button", { name: "Advanced options" }), + ).toHaveAttribute("aria-expanded", "false"); }); - test("leaves the option unchecked for an existing proxy connection", async () => { + test("keeps an existing proxy connection on the proxy server", async () => { const user = userEvent.setup(); - const config = { - ...createRandomSavedConnection(), - connection: { graphDbUrl: "https://database.example.com:8182" }, - }; - const store = renderCreateConnection( - , - ); - store.set(configurationAtom, new Map([[config.id, config]])); + const { store, config } = renderEditing({ + graphDbUrl: "https://database.example.com:8182", + }); - await openAdvancedOptions(user); - expect(screen.getByRole("checkbox", directOption)).not.toBeChecked(); + expect(screen.getByRole("radio", proxyOption)).toBeChecked(); await user.click( screen.getByRole("button", { name: "Update Connection" }), @@ -292,39 +337,16 @@ describe("CreateConnection", () => { expect(savedConnection?.connection).not.toHaveProperty("proxyConnection"); }); - test("saves an existing direct connection back to a proxy connection when unchecked", async () => { + test("saves an existing direct connection back to a proxy connection", async () => { const user = userEvent.setup(); - const config = { - ...createRandomSavedConnection(), - connection: { - graphDbUrl: "https://database.example.com:8182", - proxyConnection: false, - }, - }; - const store = renderCreateConnection( - , - ); - store.set(configurationAtom, new Map([[config.id, config]])); - - expect( - screen.getByRole("button", { name: "Advanced options" }), - ).toHaveAttribute("aria-expanded", "true"); - expect(screen.getByRole("checkbox", directOption)).toBeChecked(); + const { store, config } = renderEditing({ + graphDbUrl: "https://database.example.com:8182", + proxyConnection: false, + }); - await user.click(screen.getByRole("checkbox", directOption)); + await user.click(screen.getByRole("radio", proxyOption)); - expect( - screen.getByRole("checkbox", { name: "Use AWS IAM authentication" }), - ).toBeInTheDocument(); + expect(screen.getByRole("checkbox", iamOption)).toBeInTheDocument(); await user.click( screen.getByRole("button", { name: "Update Connection" }), diff --git a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx index fc6bd8ba0..0140fda29 100644 --- a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx +++ b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx @@ -37,10 +37,11 @@ import { mapToConnection, mapSavedConnectionToConnectionForm, queryEngineSchema, - serviceTypeSchema, + type SetConnectionFormField, updateConnectionForm, validateConnectionForm, } from "./connectionFormModel"; +import { ConnectionMethodField } from "./ConnectionMethodField"; const CONNECTIONS_OP: { label: string; @@ -153,23 +154,15 @@ const CreateConnection = ({ ); const [showErrors, setShowErrors] = useState(false); - const setField = - (field: Field) => - (value: ConnectionFormValues[Field]) => - setForm(prev => updateConnectionForm(prev, field, value)); + const setField: SetConnectionFormField = field => value => + setForm(prev => updateConnectionForm(prev, field, value)); // A number field reports `null` once it is cleared, despite its typing. const setNumberField = (field: "fetchTimeoutMs" | "nodeExpansionLimit") => (value: number | null) => setField(field)(value ?? undefined); const setCheckedField = - ( - field: - | "awsAuthEnabled" - | "fetchTimeoutEnabled" - | "nodeExpansionLimitEnabled" - | "directConnection", - ) => + (field: "fetchTimeoutEnabled" | "nodeExpansionLimitEnabled") => (checked: boolean | "indeterminate") => setField(field)(checked === true); @@ -207,9 +200,9 @@ const CreateConnection = ({ Provide the endpoint URL for your graph database, e.g., an Amazon Neptune cluster endpoint, a Gremlin Server URL, or a SPARQL - endpoint. Unless you connect directly from the browser, the Graph - Explorer server connects to this endpoint, so it must be reachable - from the host where Graph Explorer runs. + endpoint. Unless the connection method is Directly via browser, + the Graph Explorer server connects to this endpoint, so it must be + reachable from the host where Graph Explorer runs. - {!form.directConnection && ( - - )} - {!form.directConnection && form.awsAuthEnabled && ( - <> - - - - - - - - setField("serviceType")(serviceTypeSchema.parse(value)) - } - /> - - - )} + )} - - - diff --git a/packages/graph-explorer/src/modules/CreateConnection/connectionFormModel.test.ts b/packages/graph-explorer/src/modules/CreateConnection/connectionFormModel.test.ts index 454bfeca4..264fdb4cc 100644 --- a/packages/graph-explorer/src/modules/CreateConnection/connectionFormModel.test.ts +++ b/packages/graph-explorer/src/modules/CreateConnection/connectionFormModel.test.ts @@ -356,7 +356,7 @@ describe("validateConnectionForm", () => { valid: false, errors: { graphDbUrl: - "A direct connection needs a full URL starting with http:// or https://", + "Directly via browser needs a full URL starting with http:// or https://", }, }); }, @@ -500,8 +500,13 @@ describe("hasAdvancedOverrides", () => { test.each([ { fetchTimeoutEnabled: true }, { nodeExpansionLimitEnabled: true }, - { directConnection: true }, ])("is true when %o", override => { expect(hasAdvancedOverrides(createValidForm(override))).toBe(true); }); + + test("is false for a direct connection, which is not an advanced option", () => { + expect( + hasAdvancedOverrides(createValidForm({ directConnection: true })), + ).toBe(false); + }); }); diff --git a/packages/graph-explorer/src/modules/CreateConnection/connectionFormModel.ts b/packages/graph-explorer/src/modules/CreateConnection/connectionFormModel.ts index ba222a919..022d1df4f 100644 --- a/packages/graph-explorer/src/modules/CreateConnection/connectionFormModel.ts +++ b/packages/graph-explorer/src/modules/CreateConnection/connectionFormModel.ts @@ -46,6 +46,11 @@ export type ConnectionFormValues = { nodeExpansionLimit: number | undefined; }; +/** Curried setter for one field of the form. */ +export type SetConnectionFormField = ( + field: Field, +) => (value: ConnectionFormValues[Field]) => void; + /** The message to show beside each field that failed validation. */ export type ConnectionFormErrors = { name?: string; @@ -180,11 +185,7 @@ export function updateConnectionForm( * collapsed section, so editing it looks like the defaults are in force. */ export function hasAdvancedOverrides(form: ConnectionFormValues): boolean { - return ( - form.fetchTimeoutEnabled || - form.nodeExpansionLimitEnabled || - form.directConnection - ); + return form.fetchTimeoutEnabled || form.nodeExpansionLimitEnabled; } function normalizeUrlField(value: string) { @@ -197,6 +198,6 @@ function validateGraphDbUrl(values: ConnectionFormValues): string | undefined { } // The browser resolves anything else against this page or as a scheme. if (values.directConnection && !isAbsoluteHttpUrl(values.graphDbUrl)) { - return "A direct connection needs a full URL starting with http:// or https://"; + return "Directly via browser needs a full URL starting with http:// or https://"; } } diff --git a/packages/graph-explorer/src/routes/Connections/Connections.test.tsx b/packages/graph-explorer/src/routes/Connections/Connections.test.tsx index aba5301ed..122689935 100644 --- a/packages/graph-explorer/src/routes/Connections/Connections.test.tsx +++ b/packages/graph-explorer/src/routes/Connections/Connections.test.tsx @@ -34,11 +34,8 @@ async function addConnection(graphDbUrl: string, { direct = false } = {}) { graphDbUrl, ); if (direct) { - await user.click(screen.getByRole("button", { name: "Advanced options" })); await user.click( - screen.getByRole("checkbox", { - name: /Connect directly from the browser/, - }), + screen.getByRole("radio", { name: "Directly via browser" }), ); } await user.click(screen.getByRole("button", { name: "Add Connection" })); diff --git a/packages/graph-explorer/src/utils/createDisplayError.test.ts b/packages/graph-explorer/src/utils/createDisplayError.test.ts index 9c9d831af..4427a85b8 100644 --- a/packages/graph-explorer/src/utils/createDisplayError.test.ts +++ b/packages/graph-explorer/src/utils/createDisplayError.test.ts @@ -303,7 +303,7 @@ describe("createDisplayError", () => { expect(result).toStrictEqual({ title: "Database not reachable from the browser", message: - "This direct connection sends requests from the browser, so the database must be running at the Database URL and allow cross-origin requests from this page. Check the URL and the database's CORS settings, or edit the connection and uncheck Connect directly from the browser under Advanced options.", + "This connection sends requests directly from the browser, so the database must be running at the Database URL and allow cross-origin requests from this page. Check the URL and the database's CORS settings, or edit the connection and set Connection method to Via proxy server.", }); }); @@ -327,7 +327,7 @@ describe("createDisplayError", () => { expect(result).toStrictEqual({ title: "Insecure database URL", message: - "This page uses HTTPS, so the browser likely blocked the request to this http:// database. Use an https:// Database URL, or edit the connection and uncheck Connect directly from the browser under Advanced options. If your browser allows insecure content for this site, also check that the database is running and allows cross-origin requests from this page.", + "This page uses HTTPS, so the browser likely blocked the request to this http:// database. Use an https:// Database URL, or edit the connection and set Connection method to Via proxy server. If your browser allows insecure content for this site, also check that the database is running and allows cross-origin requests from this page.", }); }); diff --git a/packages/graph-explorer/src/utils/createDisplayError.ts b/packages/graph-explorer/src/utils/createDisplayError.ts index 3b6a2a569..08da71d30 100644 --- a/packages/graph-explorer/src/utils/createDisplayError.ts +++ b/packages/graph-explorer/src/utils/createDisplayError.ts @@ -143,7 +143,7 @@ export function createDisplayError(error: any): DisplayError { return { title: "Database not reachable from the browser", message: - "This direct connection sends requests from the browser, so the database must be running at the Database URL and allow cross-origin requests from this page. Check the URL and the database's CORS settings, or edit the connection and uncheck Connect directly from the browser under Advanced options.", + "This connection sends requests directly from the browser, so the database must be running at the Database URL and allow cross-origin requests from this page. Check the URL and the database's CORS settings, or edit the connection and set Connection method to Via proxy server.", }; } @@ -163,7 +163,7 @@ export function createDisplayError(error: any): DisplayError { return { title: "Insecure database URL", message: - "This page uses HTTPS, so the browser likely blocked the request to this http:// database. Use an https:// Database URL, or edit the connection and uncheck Connect directly from the browser under Advanced options. If your browser allows insecure content for this site, also check that the database is running and allows cross-origin requests from this page.", + "This page uses HTTPS, so the browser likely blocked the request to this http:// database. Use an https:// Database URL, or edit the connection and set Connection method to Via proxy server. If your browser allows insecure content for this site, also check that the database is running and allows cross-origin requests from this page.", }; }