diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..db76088
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,4 @@
+VITE_AUTH0_DOMAIN=
+VITE_AUTH0_CLIENT_ID=
+VITE_AUTH0_AUDIENCE=
+VITE_API_URL=
diff --git a/deployments/stitch-frontend/package-lock.json b/deployments/stitch-frontend/package-lock.json
index 1a23ae5..1fdce74 100644
--- a/deployments/stitch-frontend/package-lock.json
+++ b/deployments/stitch-frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "stitch-frontend",
"version": "0.0.0",
"dependencies": {
+ "@auth0/auth0-react": "^2.15.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.16",
"react": "^19.2.0",
@@ -113,6 +114,41 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@auth0/auth0-auth-js": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@auth0/auth0-auth-js/-/auth0-auth-js-1.4.0.tgz",
+ "integrity": "sha512-ShA7KT4KvcBEtxsXZTcrmoNxai5q1JXhB2aEBFnZD1L6LNLzzmiUWiFTtGMsaaITCylr8TJ/onEQk6XZmUHXbg==",
+ "license": "MIT",
+ "dependencies": {
+ "jose": "^6.0.8",
+ "openid-client": "^6.8.0"
+ }
+ },
+ "node_modules/@auth0/auth0-react": {
+ "version": "2.15.0",
+ "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.15.0.tgz",
+ "integrity": "sha512-LbRU87U54/YW/N3UHtNVoj3mCBBz+iYAdAByQjbXOkpI6IYnjMBwIwDusW3N23XNXq9WnihD57Dyi2R3/Q9btw==",
+ "license": "MIT",
+ "dependencies": {
+ "@auth0/auth0-spa-js": "^2.16.0"
+ },
+ "peerDependencies": {
+ "react": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1",
+ "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
+ }
+ },
+ "node_modules/@auth0/auth0-spa-js": {
+ "version": "2.16.0",
+ "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.16.0.tgz",
+ "integrity": "sha512-UTP45NqjC2jVc/WaWh+iYOZt6FajpTJc+3WzljbXBiv2f76wDw4Mt9hW/aShBovsRmvKEIHaCifD3c/Gxmo2ZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@auth0/auth0-auth-js": "^1.4.0",
+ "browser-tabs-lock": "^1.2.15",
+ "dpop": "^2.1.1",
+ "es-cookie": "~1.3.2"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -2388,6 +2424,16 @@
"concat-map": "0.0.1"
}
},
+ "node_modules/browser-tabs-lock": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz",
+ "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash": ">=4.17.21"
+ }
+ },
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -2682,6 +2728,15 @@
"license": "MIT",
"peer": true
},
+ "node_modules/dpop": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz",
+ "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -2729,6 +2784,12 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/es-cookie": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz",
+ "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==",
+ "license": "MIT"
+ },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -3481,6 +3542,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
+ "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3877,6 +3947,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4051,6 +4127,28 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/oauth4webapi": {
+ "version": "3.8.5",
+ "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
+ "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/openid-client": {
+ "version": "6.8.2",
+ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz",
+ "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==",
+ "license": "MIT",
+ "dependencies": {
+ "jose": "^6.1.3",
+ "oauth4webapi": "^3.8.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
diff --git a/deployments/stitch-frontend/package.json b/deployments/stitch-frontend/package.json
index 22d99f5..01b22a4 100644
--- a/deployments/stitch-frontend/package.json
+++ b/deployments/stitch-frontend/package.json
@@ -16,6 +16,7 @@
"test:coverage": "vitest --coverage"
},
"dependencies": {
+ "@auth0/auth0-react": "^2.15.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.16",
"react": "^19.2.0",
diff --git a/deployments/stitch-frontend/src/App.jsx b/deployments/stitch-frontend/src/App.jsx
index 7c25b62..0568759 100644
--- a/deployments/stitch-frontend/src/App.jsx
+++ b/deployments/stitch-frontend/src/App.jsx
@@ -1,9 +1,13 @@
import ResourcesView from "./components/ResourcesView";
import ResourceView from "./components/ResourceView";
+import { LogoutButton } from "./components/LogoutButton";
function App() {
return (
diff --git a/deployments/stitch-frontend/src/auth/AuthGate.jsx b/deployments/stitch-frontend/src/auth/AuthGate.jsx
new file mode 100644
index 0000000..19cefd0
--- /dev/null
+++ b/deployments/stitch-frontend/src/auth/AuthGate.jsx
@@ -0,0 +1,30 @@
+import { useAuth0 } from "@auth0/auth0-react";
+import LoginPage from "../components/LoginPage";
+
+export default function AuthGate({ children }) {
+ const { isLoading, isAuthenticated, error } = useAuth0();
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ Authentication error: {error.message}
+
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return children;
+}
diff --git a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx
new file mode 100644
index 0000000..7b39d3f
--- /dev/null
+++ b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { useAuth0 } from "@auth0/auth0-react";
+import { auth0TestDefaults } from "../test/utils";
+import AuthGate from "./AuthGate";
+
+describe("AuthGate", () => {
+ it("shows loading indicator while auth is loading", () => {
+ vi.mocked(useAuth0).mockReturnValue({
+ ...auth0TestDefaults,
+ isLoading: true,
+ isAuthenticated: false,
+ });
+
+ render(
+
+ App Content
+ ,
+ );
+
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+ expect(screen.queryByText("App Content")).not.toBeInTheDocument();
+ });
+
+ it("shows error message when auth fails", () => {
+ vi.mocked(useAuth0).mockReturnValue({
+ ...auth0TestDefaults,
+ isAuthenticated: false,
+ error: new Error("Something went wrong"),
+ });
+
+ render(
+
+ App Content
+ ,
+ );
+
+ expect(
+ screen.getByText("Authentication error: Something went wrong"),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("App Content")).not.toBeInTheDocument();
+ });
+
+ it("renders LoginPage when unauthenticated", () => {
+ vi.mocked(useAuth0).mockReturnValue({
+ ...auth0TestDefaults,
+ isAuthenticated: false,
+ });
+
+ render(
+
+ App Content
+ ,
+ );
+
+ expect(screen.getByText("Stitch")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /log in to continue/i }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("App Content")).not.toBeInTheDocument();
+ });
+});
diff --git a/deployments/stitch-frontend/src/auth/api.js b/deployments/stitch-frontend/src/auth/api.js
new file mode 100644
index 0000000..80ba100
--- /dev/null
+++ b/deployments/stitch-frontend/src/auth/api.js
@@ -0,0 +1,21 @@
+import config from "../config/env";
+
+/**
+ * Returns a `fetch`-compatible function that automatically attaches a Bearer
+ * token to every request. The token is acquired on each call via Auth0's
+ * `getAccessTokenSilently`, so callers never handle tokens directly.
+ *
+ * @param {Function} getAccessTokenSilently - Auth0 SDK method for obtaining
+ * an access token without user interaction.
+ * @returns {Function} An async `(url, options?) => Response` fetcher.
+ */
+export function createAuthenticatedFetcher(getAccessTokenSilently) {
+ return async (url, options = {}) => {
+ const token = await getAccessTokenSilently({
+ authorizationParams: { audience: config.auth0.audience },
+ });
+ const headers = new Headers(options.headers);
+ headers.set("Authorization", `Bearer ${token}`);
+ return fetch(url, { ...options, headers });
+ };
+}
diff --git a/deployments/stitch-frontend/src/auth/api.test.js b/deployments/stitch-frontend/src/auth/api.test.js
new file mode 100644
index 0000000..3cbf848
--- /dev/null
+++ b/deployments/stitch-frontend/src/auth/api.test.js
@@ -0,0 +1,68 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { createAuthenticatedFetcher } from "./api";
+
+describe("createAuthenticatedFetcher", () => {
+ let getAccessTokenSilently;
+ let fetcher;
+
+ beforeEach(() => {
+ getAccessTokenSilently = vi.fn().mockResolvedValue("test-token");
+ fetcher = createAuthenticatedFetcher(getAccessTokenSilently);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }))),
+ );
+ });
+
+ it("attaches Bearer token to request", async () => {
+ await fetcher("http://api.test/data");
+
+ const [, options] = fetch.mock.calls[0];
+ expect(options.headers.get("Authorization")).toBe("Bearer test-token");
+ });
+
+ it("passes audience to getAccessTokenSilently", async () => {
+ await fetcher("http://api.test/data");
+
+ expect(getAccessTokenSilently).toHaveBeenCalledWith({
+ authorizationParams: { audience: "https://test-api" },
+ });
+ });
+
+ it("preserves caller headers", async () => {
+ await fetcher("http://api.test/data", {
+ headers: { "Content-Type": "application/json" },
+ });
+
+ const [, options] = fetch.mock.calls[0];
+ expect(options.headers.get("Content-Type")).toBe("application/json");
+ expect(options.headers.get("Authorization")).toBe("Bearer test-token");
+ });
+
+ it("overrides caller Authorization with token", async () => {
+ await fetcher("http://api.test/data", {
+ headers: { Authorization: "Bearer old-token" },
+ });
+
+ const [, options] = fetch.mock.calls[0];
+ expect(options.headers.get("Authorization")).toBe("Bearer test-token");
+ });
+
+ it("propagates token acquisition errors", async () => {
+ getAccessTokenSilently.mockRejectedValue(new Error("token error"));
+ fetcher = createAuthenticatedFetcher(getAccessTokenSilently);
+
+ await expect(fetcher("http://api.test/data")).rejects.toThrow(
+ "token error",
+ );
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("propagates fetch errors", async () => {
+ fetch.mockRejectedValue(new Error("network error"));
+
+ await expect(fetcher("http://api.test/data")).rejects.toThrow(
+ "network error",
+ );
+ });
+});
diff --git a/deployments/stitch-frontend/src/components/LoginPage.jsx b/deployments/stitch-frontend/src/components/LoginPage.jsx
new file mode 100644
index 0000000..74bfa82
--- /dev/null
+++ b/deployments/stitch-frontend/src/components/LoginPage.jsx
@@ -0,0 +1,22 @@
+import { useAuth0 } from "@auth0/auth0-react";
+import Button from "./Button";
+
+export default function LoginPage() {
+ const { loginWithRedirect } = useAuth0();
+
+ return (
+
+
+
Stitch
+
+ Oil & Gas Asset Data Platform
+
+
+ Integrate diverse datasets, apply AI-driven enrichment with human
+ review, and deliver curated, trustworthy data.
+
+
+
+
+ );
+}
diff --git a/deployments/stitch-frontend/src/components/LoginPage.test.jsx b/deployments/stitch-frontend/src/components/LoginPage.test.jsx
new file mode 100644
index 0000000..931e44a
--- /dev/null
+++ b/deployments/stitch-frontend/src/components/LoginPage.test.jsx
@@ -0,0 +1,33 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useAuth0 } from "@auth0/auth0-react";
+import { auth0TestDefaults } from "../test/utils";
+import LoginPage from "./LoginPage";
+
+describe("LoginPage", () => {
+ it("renders app name and description", () => {
+ render();
+
+ expect(screen.getByText("Stitch")).toBeInTheDocument();
+ expect(
+ screen.getByText("Oil & Gas Asset Data Platform"),
+ ).toBeInTheDocument();
+ });
+
+ it("calls loginWithRedirect on button click", async () => {
+ const loginWithRedirect = vi.fn();
+ vi.mocked(useAuth0).mockReturnValue({
+ ...auth0TestDefaults,
+ loginWithRedirect,
+ });
+
+ const user = userEvent.setup();
+ render();
+ await user.click(
+ screen.getByRole("button", { name: /log in to continue/i }),
+ );
+
+ expect(loginWithRedirect).toHaveBeenCalled();
+ });
+});
diff --git a/deployments/stitch-frontend/src/components/LogoutButton.jsx b/deployments/stitch-frontend/src/components/LogoutButton.jsx
new file mode 100644
index 0000000..4942842
--- /dev/null
+++ b/deployments/stitch-frontend/src/components/LogoutButton.jsx
@@ -0,0 +1,13 @@
+import { useAuth0 } from "@auth0/auth0-react";
+import Button from "./Button";
+
+export const LogoutButton = () => {
+ const { isAuthenticated, logout: authLogout } = useAuth0();
+
+ const logout = () =>
+ authLogout({
+ openUrl: () => window.location.assign(window.location.origin),
+ });
+
+ return isAuthenticated && ;
+};
diff --git a/deployments/stitch-frontend/src/components/LogoutButton.test.jsx b/deployments/stitch-frontend/src/components/LogoutButton.test.jsx
new file mode 100644
index 0000000..197ce5a
--- /dev/null
+++ b/deployments/stitch-frontend/src/components/LogoutButton.test.jsx
@@ -0,0 +1,38 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useAuth0 } from "@auth0/auth0-react";
+import { auth0TestDefaults } from "../test/utils";
+import { LogoutButton } from "./LogoutButton";
+
+describe("LogoutButton", () => {
+ it("renders when authenticated", () => {
+ render();
+ expect(
+ screen.getByRole("button", { name: /log out/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("does not render when not authenticated", () => {
+ vi.mocked(useAuth0).mockReturnValue({
+ ...auth0TestDefaults,
+ isAuthenticated: false,
+ });
+
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("calls logout with returnTo on click", async () => {
+ const logout = vi.fn();
+ vi.mocked(useAuth0).mockReturnValue({ ...auth0TestDefaults, logout });
+
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: /log out/i }));
+
+ expect(logout).toHaveBeenCalledWith({
+ openUrl: expect.any(Function),
+ });
+ });
+});
diff --git a/deployments/stitch-frontend/src/components/ResourceView.jsx b/deployments/stitch-frontend/src/components/ResourceView.jsx
index 65ba525..b393a79 100644
--- a/deployments/stitch-frontend/src/components/ResourceView.jsx
+++ b/deployments/stitch-frontend/src/components/ResourceView.jsx
@@ -6,6 +6,7 @@ import ClearCacheButton from "./ClearCacheButton";
import JsonView from "./JsonView";
import Input from "./Input";
import { resourceKeys } from "../queries/resources";
+import config from "../config/env";
export default function ResourceView({ className, endpoint }) {
const queryClient = useQueryClient();
@@ -29,7 +30,7 @@ export default function ResourceView({ className, endpoint }) {
- {import.meta.env.VITE_API_URL}
+ {config.apiBaseUrl}
{endpoint}
diff --git a/deployments/stitch-frontend/src/config/env.js b/deployments/stitch-frontend/src/config/env.js
new file mode 100644
index 0000000..6adef6c
--- /dev/null
+++ b/deployments/stitch-frontend/src/config/env.js
@@ -0,0 +1,30 @@
+const REQUIRED_KEYS = [
+ "VITE_AUTH0_DOMAIN",
+ "VITE_AUTH0_CLIENT_ID",
+ "VITE_AUTH0_AUDIENCE",
+];
+
+function loadConfig() {
+ const missing = REQUIRED_KEYS.filter((key) => !import.meta.env[key]);
+
+ if (missing.length > 0) {
+ throw new Error(
+ `Missing required environment variables: ${missing.join(", ")}. ` +
+ `Check your .env file or deployment config.`,
+ );
+ }
+
+ return Object.freeze({
+ auth0: Object.freeze({
+ domain: import.meta.env.VITE_AUTH0_DOMAIN,
+ clientId: import.meta.env.VITE_AUTH0_CLIENT_ID,
+ audience: import.meta.env.VITE_AUTH0_AUDIENCE,
+ }),
+ apiBaseUrl: import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1",
+ });
+}
+
+// Optional named export for `import { config } from "./config/env.js"`
+export const config = loadConfig();
+
+export default config;
diff --git a/deployments/stitch-frontend/src/config/env.test.js b/deployments/stitch-frontend/src/config/env.test.js
new file mode 100644
index 0000000..3e45544
--- /dev/null
+++ b/deployments/stitch-frontend/src/config/env.test.js
@@ -0,0 +1,40 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+describe("config/env", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it("returns correct config when all vars are present", async () => {
+ vi.stubEnv("VITE_AUTH0_DOMAIN", "my.auth0.com");
+ vi.stubEnv("VITE_AUTH0_CLIENT_ID", "my-client-id");
+ vi.stubEnv("VITE_AUTH0_AUDIENCE", "https://my-api");
+ vi.stubEnv("VITE_API_URL", "http://localhost:9000/api/v1");
+
+ const { default: config } = await import("./env.js");
+
+ expect(config.auth0.domain).toBe("my.auth0.com");
+ expect(config.auth0.clientId).toBe("my-client-id");
+ expect(config.auth0.audience).toBe("https://my-api");
+ expect(config.apiBaseUrl).toBe("http://localhost:9000/api/v1");
+ });
+
+ it("throws when required vars are missing", async () => {
+ vi.stubEnv("VITE_AUTH0_DOMAIN", "");
+ vi.stubEnv("VITE_AUTH0_CLIENT_ID", "");
+ vi.stubEnv("VITE_AUTH0_AUDIENCE", "");
+
+ await expect(() => import("./env.js")).rejects.toThrow("VITE_AUTH0_DOMAIN");
+ });
+
+ it("uses default API URL when VITE_API_URL is unset", async () => {
+ vi.stubEnv("VITE_AUTH0_DOMAIN", "my.auth0.com");
+ vi.stubEnv("VITE_AUTH0_CLIENT_ID", "my-client-id");
+ vi.stubEnv("VITE_AUTH0_AUDIENCE", "https://my-api");
+ vi.stubEnv("VITE_API_URL", "");
+
+ const { default: config } = await import("./env.js");
+
+ expect(config.apiBaseUrl).toBe("http://localhost:8000/api/v1");
+ });
+});
diff --git a/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js
new file mode 100644
index 0000000..32d0398
--- /dev/null
+++ b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js
@@ -0,0 +1,21 @@
+import { useQuery } from "@tanstack/react-query";
+import { useAuth0 } from "@auth0/auth0-react";
+import { createAuthenticatedFetcher } from "../auth/api";
+
+/**
+ * Wraps `useQuery` so that every request carries a valid Auth0 bearer token.
+ *
+ * Callers provide a `queryFn(fetcher)` instead of a plain `queryFn()`. The
+ * hook builds an authenticated fetcher (via `createAuthenticatedFetcher`) and
+ * passes it into `queryFn`, keeping token acquisition out of individual query
+ * functions.
+ *
+ * @param {object} queryOptions - Standard TanStack Query options, except
+ * `queryFn` receives an authenticated `fetcher` as its first argument.
+ */
+export function useAuthenticatedQuery(queryOptions) {
+ const { getAccessTokenSilently } = useAuth0();
+ const fetcher = createAuthenticatedFetcher(getAccessTokenSilently);
+ const { queryFn, ...rest } = queryOptions;
+ return useQuery({ ...rest, queryFn: () => queryFn(fetcher) });
+}
diff --git a/deployments/stitch-frontend/src/hooks/useResources.js b/deployments/stitch-frontend/src/hooks/useResources.js
index 5957e20..dad38b3 100644
--- a/deployments/stitch-frontend/src/hooks/useResources.js
+++ b/deployments/stitch-frontend/src/hooks/useResources.js
@@ -1,10 +1,10 @@
-import { useQuery } from "@tanstack/react-query";
+import { useAuthenticatedQuery } from "./useAuthenticatedQuery";
import { resourceQueries } from "../queries/resources";
export function useResources() {
- return useQuery(resourceQueries.list());
+ return useAuthenticatedQuery(resourceQueries.list());
}
export function useResource(id) {
- return useQuery(resourceQueries.detail(id));
+ return useAuthenticatedQuery(resourceQueries.detail(id));
}
diff --git a/deployments/stitch-frontend/src/main.jsx b/deployments/stitch-frontend/src/main.jsx
index d489c37..e4221ad 100644
--- a/deployments/stitch-frontend/src/main.jsx
+++ b/deployments/stitch-frontend/src/main.jsx
@@ -1,8 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { Auth0Provider } from "@auth0/auth0-react";
import "./index.css";
import App from "./App.jsx";
+import AuthGate from "./auth/AuthGate";
+import config from "./config/env";
// Set global defaults for QueryClient
const queryClient = new QueryClient({
@@ -16,8 +19,20 @@ const queryClient = new QueryClient({
createRoot(document.getElementById("root")).render(
-
-
-
+
+
+
+
+
+
+
,
);
diff --git a/deployments/stitch-frontend/src/queries/api.js b/deployments/stitch-frontend/src/queries/api.js
index d585eb5..91f54d9 100644
--- a/deployments/stitch-frontend/src/queries/api.js
+++ b/deployments/stitch-frontend/src/queries/api.js
@@ -1,9 +1,8 @@
-const API_BASE_URL =
- import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1";
+import config from "../config/env";
-export async function getResources() {
- const url = `${API_BASE_URL}/resources/`;
- const response = await fetch(url);
+export async function getResources(fetcher) {
+ const url = `${config.apiBaseUrl}/resources/`;
+ const response = await fetcher(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -11,9 +10,9 @@ export async function getResources() {
return data;
}
-export async function getResource(id) {
- const url = `${API_BASE_URL}/resources/${id}`;
- const response = await fetch(url);
+export async function getResource(id, fetcher) {
+ const url = `${config.apiBaseUrl}/resources/${id}`;
+ const response = await fetcher(url);
if (!response.ok) {
const error = new Error(`HTTP error! status: ${response.status}`);
error.status = response.status;
diff --git a/deployments/stitch-frontend/src/queries/api.test.js b/deployments/stitch-frontend/src/queries/api.test.js
index 54425a3..3645043 100644
--- a/deployments/stitch-frontend/src/queries/api.test.js
+++ b/deployments/stitch-frontend/src/queries/api.test.js
@@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { getResources, getResource } from "./api";
describe("API Functions", () => {
+ let mockFetcher;
+
beforeEach(() => {
- global.fetch = vi.fn();
+ mockFetcher = vi.fn();
});
describe("getResources", () => {
@@ -13,33 +15,35 @@ describe("API Functions", () => {
{ id: 2, name: "Resource 2", type: "test" },
];
- global.fetch.mockResolvedValueOnce({
+ mockFetcher.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockResources,
});
- const result = await getResources();
+ const result = await getResources(mockFetcher);
- expect(global.fetch).toHaveBeenCalledWith(
+ expect(mockFetcher).toHaveBeenCalledWith(
"http://localhost:8000/api/v1/resources/",
);
expect(result).toEqual(mockResources);
});
it("throws error when response is not ok", async () => {
- global.fetch.mockResolvedValueOnce({
+ mockFetcher.mockResolvedValueOnce({
ok: false,
status: 500,
});
- await expect(getResources()).rejects.toThrow("HTTP error! status: 500");
+ await expect(getResources(mockFetcher)).rejects.toThrow(
+ "HTTP error! status: 500",
+ );
});
it("throws error on network failure", async () => {
- global.fetch.mockRejectedValueOnce(new Error("Network error"));
+ mockFetcher.mockRejectedValueOnce(new Error("Network error"));
- await expect(getResources()).rejects.toThrow("Network error");
+ await expect(getResources(mockFetcher)).rejects.toThrow("Network error");
});
});
@@ -47,28 +51,28 @@ describe("API Functions", () => {
it("fetches and returns a single resource successfully", async () => {
const mockResource = { id: 42, name: "Test Resource", type: "example" };
- global.fetch.mockResolvedValueOnce({
+ mockFetcher.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockResource,
});
- const result = await getResource(42);
+ const result = await getResource(42, mockFetcher);
- expect(global.fetch).toHaveBeenCalledWith(
+ expect(mockFetcher).toHaveBeenCalledWith(
"http://localhost:8000/api/v1/resources/42",
);
expect(result).toEqual(mockResource);
});
it("throws error with status when response is not ok", async () => {
- global.fetch.mockResolvedValueOnce({
+ mockFetcher.mockResolvedValueOnce({
ok: false,
status: 404,
});
try {
- await getResource(999);
+ await getResource(999, mockFetcher);
expect.fail("Should have thrown an error");
} catch (error) {
expect(error.message).toBe("HTTP error! status: 404");
@@ -77,33 +81,35 @@ describe("API Functions", () => {
});
it("includes status code in error object for 404", async () => {
- global.fetch.mockResolvedValueOnce({
+ mockFetcher.mockResolvedValueOnce({
ok: false,
status: 404,
});
- await expect(getResource(123)).rejects.toMatchObject({
+ await expect(getResource(123, mockFetcher)).rejects.toMatchObject({
message: "HTTP error! status: 404",
status: 404,
});
});
it("includes status code in error object for 500", async () => {
- global.fetch.mockResolvedValueOnce({
+ mockFetcher.mockResolvedValueOnce({
ok: false,
status: 500,
});
- await expect(getResource(1)).rejects.toMatchObject({
+ await expect(getResource(1, mockFetcher)).rejects.toMatchObject({
message: "HTTP error! status: 500",
status: 500,
});
});
it("throws error on network failure", async () => {
- global.fetch.mockRejectedValueOnce(new Error("Failed to fetch"));
+ mockFetcher.mockRejectedValueOnce(new Error("Failed to fetch"));
- await expect(getResource(1)).rejects.toThrow("Failed to fetch");
+ await expect(getResource(1, mockFetcher)).rejects.toThrow(
+ "Failed to fetch",
+ );
});
});
});
diff --git a/deployments/stitch-frontend/src/queries/resources.js b/deployments/stitch-frontend/src/queries/resources.js
index a2b497d..3ff7413 100644
--- a/deployments/stitch-frontend/src/queries/resources.js
+++ b/deployments/stitch-frontend/src/queries/resources.js
@@ -13,13 +13,13 @@ export const resourceKeys = {
export const resourceQueries = {
list: () => ({
queryKey: resourceKeys.lists(),
- queryFn: getResources,
+ queryFn: (fetcher) => getResources(fetcher),
enabled: false,
}),
detail: (id) => ({
queryKey: resourceKeys.detail(id),
- queryFn: () => getResource(id),
+ queryFn: (fetcher) => getResource(id, fetcher),
enabled: false,
}),
};
diff --git a/deployments/stitch-frontend/src/test/setup.js b/deployments/stitch-frontend/src/test/setup.js
index 971941d..9478604 100644
--- a/deployments/stitch-frontend/src/test/setup.js
+++ b/deployments/stitch-frontend/src/test/setup.js
@@ -1,4 +1,4 @@
-import { expect, afterEach } from "vitest";
+import { expect, afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
import * as matchers from "@testing-library/jest-dom/matchers";
@@ -7,3 +7,16 @@ expect.extend(matchers);
afterEach(() => {
cleanup();
});
+
+vi.mock("@auth0/auth0-react", () => ({
+ useAuth0: vi.fn().mockReturnValue({
+ isAuthenticated: true,
+ isLoading: false,
+ error: null,
+ user: { sub: "test-user-id", email: "test@example.com" },
+ getAccessTokenSilently: vi.fn().mockResolvedValue("test-access-token"),
+ loginWithRedirect: vi.fn(),
+ logout: vi.fn(),
+ }),
+ Auth0Provider: ({ children }) => children,
+}));
diff --git a/deployments/stitch-frontend/src/test/utils.jsx b/deployments/stitch-frontend/src/test/utils.jsx
index cc13656..cc69045 100644
--- a/deployments/stitch-frontend/src/test/utils.jsx
+++ b/deployments/stitch-frontend/src/test/utils.jsx
@@ -1,6 +1,23 @@
+import { vi } from "vitest";
import { render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+/**
+ * Default return value for the mocked `useAuth0` hook (mirrors setup.js).
+ * Spread and override in tests that need a different auth state:
+ *
+ * vi.mocked(useAuth0).mockReturnValue({ ...auth0Defaults, isAuthenticated: false });
+ */
+export const auth0TestDefaults = {
+ isAuthenticated: true,
+ isLoading: false,
+ error: null,
+ user: { sub: "test-user-id", email: "test@example.com" },
+ getAccessTokenSilently: vi.fn().mockResolvedValue("test-access-token"),
+ loginWithRedirect: vi.fn(),
+ logout: vi.fn(),
+};
+
export function renderWithQueryClient(ui, options = {}) {
const queryClient = new QueryClient({
defaultOptions: {
diff --git a/deployments/stitch-frontend/vitest.config.js b/deployments/stitch-frontend/vitest.config.js
index 908aa37..606ff9b 100644
--- a/deployments/stitch-frontend/vitest.config.js
+++ b/deployments/stitch-frontend/vitest.config.js
@@ -8,5 +8,11 @@ export default defineConfig({
environment: "jsdom",
setupFiles: "./src/test/setup.js",
css: true,
+ env: {
+ VITE_AUTH0_DOMAIN: "test.auth0.com",
+ VITE_AUTH0_CLIENT_ID: "test-client-id",
+ VITE_AUTH0_AUDIENCE: "https://test-api",
+ VITE_API_URL: "http://localhost:8000/api/v1",
+ },
},
});