From 0d3d731d946912e396bebbca9cfd3abc601b1439 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Wed, 18 Feb 2026 16:12:48 -0700 Subject: [PATCH 01/16] feat: add auth0 npm package --- deployments/stitch-frontend/package-lock.json | 98 +++++++++++++++++++ deployments/stitch-frontend/package.json | 1 + 2 files changed, 99 insertions(+) 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", From 4626d39f8119a1f095def559abe967e32840b7d9 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:35:46 -0700 Subject: [PATCH 02/16] chore(frontend): add .env.example and .env to .gitignore --- deployments/stitch-frontend/.env.example | 4 ++++ deployments/stitch-frontend/.gitignore | 1 + 2 files changed, 5 insertions(+) create mode 100644 deployments/stitch-frontend/.env.example diff --git a/deployments/stitch-frontend/.env.example b/deployments/stitch-frontend/.env.example new file mode 100644 index 0000000..db76088 --- /dev/null +++ b/deployments/stitch-frontend/.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/.gitignore b/deployments/stitch-frontend/.gitignore index de9ee66..62be7e5 100644 --- a/deployments/stitch-frontend/.gitignore +++ b/deployments/stitch-frontend/.gitignore @@ -7,6 +7,7 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +.env node_modules dist dist-ssr From 498db093bf9b80552c62ea9b736890e020dd6191 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:36:07 -0700 Subject: [PATCH 03/16] test(frontend): add Auth0 env vars to vitest config --- deployments/stitch-frontend/vitest.config.js | 6 ++++++ 1 file changed, 6 insertions(+) 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", + }, }, }); From 973c86e0eceb8317a751b62a8afd26be66471333 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:36:25 -0700 Subject: [PATCH 04/16] test(frontend): add global Auth0 mock to test setup --- deployments/stitch-frontend/src/test/setup.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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, +})); From 7ed9191f1a4ee6791e2fdbdb6be1994e3bb7bd49 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:37:44 -0700 Subject: [PATCH 05/16] feat(frontend): implement authenticated fetcher with tests --- deployments/stitch-frontend/src/auth/api.js | 12 ++++ .../stitch-frontend/src/auth/api.test.js | 68 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 deployments/stitch-frontend/src/auth/api.js create mode 100644 deployments/stitch-frontend/src/auth/api.test.js diff --git a/deployments/stitch-frontend/src/auth/api.js b/deployments/stitch-frontend/src/auth/api.js new file mode 100644 index 0000000..2c03d06 --- /dev/null +++ b/deployments/stitch-frontend/src/auth/api.js @@ -0,0 +1,12 @@ +import config from "../config/env"; + +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", + ); + }); +}); From cae5cd3c893f818a519f0dcc96a49124050e955d Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:38:31 -0700 Subject: [PATCH 06/16] feat(frontend): update API functions to accept fetcher parameter --- .../stitch-frontend/src/queries/api.js | 15 +++---- .../stitch-frontend/src/queries/api.test.js | 44 +++++++++++-------- 2 files changed, 32 insertions(+), 27 deletions(-) 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", + ); }); }); }); From 50748b4766f25c5a2ecd4bb9c3991bcb31aeb174 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:38:59 -0700 Subject: [PATCH 07/16] feat(frontend): add useAuthenticatedQuery hook and wire up auth to queries --- .../stitch-frontend/src/hooks/useAuthenticatedQuery.js | 10 ++++++++++ deployments/stitch-frontend/src/hooks/useResources.js | 6 +++--- deployments/stitch-frontend/src/queries/resources.js | 4 ++-- 3 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js diff --git a/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js new file mode 100644 index 0000000..01d7415 --- /dev/null +++ b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAuth0 } from "@auth0/auth0-react"; +import { createAuthenticatedFetcher } from "../auth/api"; + +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/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, }), }; From 0d74999a38eab793a969c91e2066b8238a6e8962 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:39:33 -0700 Subject: [PATCH 08/16] feat(frontend): implement AuthGate component with tests --- .../stitch-frontend/src/auth/AuthGate.jsx | 36 ++++++++ .../src/auth/AuthGate.test.jsx | 91 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 deployments/stitch-frontend/src/auth/AuthGate.jsx create mode 100644 deployments/stitch-frontend/src/auth/AuthGate.test.jsx diff --git a/deployments/stitch-frontend/src/auth/AuthGate.jsx b/deployments/stitch-frontend/src/auth/AuthGate.jsx new file mode 100644 index 0000000..9026d6b --- /dev/null +++ b/deployments/stitch-frontend/src/auth/AuthGate.jsx @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { useAuth0 } from "@auth0/auth0-react"; + +export default function AuthGate({ children }) { + const { isLoading, isAuthenticated, error, loginWithRedirect } = useAuth0(); + + useEffect(() => { + if (!isLoading && !isAuthenticated && !error) { + loginWithRedirect(); + } + }, [isLoading, isAuthenticated, error, loginWithRedirect]); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (error) { + return ( +
+

+ Authentication error: {error.message} +

+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + 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..a2140c7 --- /dev/null +++ b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { useAuth0 } from "@auth0/auth0-react"; +import AuthGate from "./AuthGate"; + +describe("AuthGate", () => { + it("shows loading indicator while auth is loading", () => { + vi.mocked(useAuth0).mockReturnValue({ + isLoading: true, + isAuthenticated: false, + error: null, + loginWithRedirect: vi.fn(), + getAccessTokenSilently: vi.fn(), + logout: vi.fn(), + }); + + render( + +
App Content
+
, + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + expect(vi.mocked(useAuth0)().loginWithRedirect).not.toHaveBeenCalled(); + }); + + it("shows error message when auth fails", () => { + const loginWithRedirect = vi.fn(); + vi.mocked(useAuth0).mockReturnValue({ + isLoading: false, + isAuthenticated: false, + error: new Error("Something went wrong"), + loginWithRedirect, + getAccessTokenSilently: vi.fn(), + logout: vi.fn(), + }); + + render( + +
App Content
+
, + ); + + expect( + screen.getByText("Authentication error: Something went wrong"), + ).toBeInTheDocument(); + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + expect(loginWithRedirect).not.toHaveBeenCalled(); + }); + + it("calls loginWithRedirect when unauthenticated", () => { + const loginWithRedirect = vi.fn(); + vi.mocked(useAuth0).mockReturnValue({ + isLoading: false, + isAuthenticated: false, + error: null, + loginWithRedirect, + getAccessTokenSilently: vi.fn(), + logout: vi.fn(), + }); + + render( + +
App Content
+
, + ); + + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + expect(loginWithRedirect).toHaveBeenCalled(); + }); + + it("renders children when authenticated", () => { + vi.mocked(useAuth0).mockReturnValue({ + isLoading: false, + isAuthenticated: true, + error: null, + loginWithRedirect: vi.fn(), + getAccessTokenSilently: vi.fn(), + logout: vi.fn(), + }); + + render( + +
App Content
+
, + ); + + expect(screen.getByText("App Content")).toBeInTheDocument(); + }); +}); From ab622c51bf1bd2b67ed2ea6583c6c4dd298e6c0d Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:40:17 -0700 Subject: [PATCH 09/16] feat(frontend): configure Auth0Provider, add AuthGate and LogoutButton to app --- deployments/stitch-frontend/src/App.jsx | 4 ++++ .../src/components/ResourceView.jsx | 3 ++- deployments/stitch-frontend/src/main.jsx | 21 ++++++++++++++++--- 3 files changed, 24 insertions(+), 4 deletions(-) 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/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/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( - - - + + + + + + + , ); From 77d4a973b8cc0ed2a365273bc852d9f5dc26508b Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:40:58 -0700 Subject: [PATCH 10/16] test(frontend): add env config tests --- .../stitch-frontend/src/config/env.test.js | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 deployments/stitch-frontend/src/config/env.test.js 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..1be3216 --- /dev/null +++ b/deployments/stitch-frontend/src/config/env.test.js @@ -0,0 +1,42 @@ +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"); + }); +}); From 6e8975fa4c56a8d4997bd0e6ffdf353b579c0b35 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 16:19:32 -0700 Subject: [PATCH 11/16] feat(frontend): add LogoutButton component and env config module --- .../src/components/LogoutButton.jsx | 11 +++++++ deployments/stitch-frontend/src/config/env.js | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 deployments/stitch-frontend/src/components/LogoutButton.jsx create mode 100644 deployments/stitch-frontend/src/config/env.js diff --git a/deployments/stitch-frontend/src/components/LogoutButton.jsx b/deployments/stitch-frontend/src/components/LogoutButton.jsx new file mode 100644 index 0000000..4fea9a6 --- /dev/null +++ b/deployments/stitch-frontend/src/components/LogoutButton.jsx @@ -0,0 +1,11 @@ +import { useAuth0 } from "@auth0/auth0-react"; +import Button from "./Button"; + +export const LogoutButton = () => { + const { isAuthenticated, logout: authLogout } = useAuth0(); + + const logout = () => + authLogout({ logoutParams: { returnTo: window.location.origin } }); + + return isAuthenticated && ; +}; diff --git a/deployments/stitch-frontend/src/config/env.js b/deployments/stitch-frontend/src/config/env.js new file mode 100644 index 0000000..3af620c --- /dev/null +++ b/deployments/stitch-frontend/src/config/env.js @@ -0,0 +1,29 @@ +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", + }); +} + +const config = loadConfig(); + +export default config; From e70cc875ede474fd6113163cda4b122b13f5b59f Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 16:22:04 -0700 Subject: [PATCH 12/16] chore: move .env.example to repo root, remove redundant frontend .env ignore --- deployments/stitch-frontend/.env.example => .env.example | 0 deployments/stitch-frontend/.gitignore | 1 - 2 files changed, 1 deletion(-) rename deployments/stitch-frontend/.env.example => .env.example (100%) diff --git a/deployments/stitch-frontend/.env.example b/.env.example similarity index 100% rename from deployments/stitch-frontend/.env.example rename to .env.example diff --git a/deployments/stitch-frontend/.gitignore b/deployments/stitch-frontend/.gitignore index 62be7e5..de9ee66 100644 --- a/deployments/stitch-frontend/.gitignore +++ b/deployments/stitch-frontend/.gitignore @@ -7,7 +7,6 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* -.env node_modules dist dist-ssr From a5ed2023a621e1e534306550fdf4fbfe9e505f1d Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 20:10:33 -0700 Subject: [PATCH 13/16] test(frontend): add LogoutButton tests, extract shared auth0 mock defaults, add JSDoc --- .../src/auth/AuthGate.test.jsx | 34 ++--------------- deployments/stitch-frontend/src/auth/api.js | 9 +++++ .../src/components/LogoutButton.test.jsx | 38 +++++++++++++++++++ deployments/stitch-frontend/src/config/env.js | 3 +- .../src/hooks/useAuthenticatedQuery.js | 11 ++++++ .../stitch-frontend/src/test/utils.jsx | 17 +++++++++ 6 files changed, 81 insertions(+), 31 deletions(-) create mode 100644 deployments/stitch-frontend/src/components/LogoutButton.test.jsx diff --git a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx index a2140c7..5d3cde6 100644 --- a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx +++ b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx @@ -1,17 +1,15 @@ 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, - error: null, - loginWithRedirect: vi.fn(), - getAccessTokenSilently: vi.fn(), - logout: vi.fn(), }); render( @@ -28,12 +26,10 @@ describe("AuthGate", () => { it("shows error message when auth fails", () => { const loginWithRedirect = vi.fn(); vi.mocked(useAuth0).mockReturnValue({ - isLoading: false, + ...auth0TestDefaults, isAuthenticated: false, error: new Error("Something went wrong"), loginWithRedirect, - getAccessTokenSilently: vi.fn(), - logout: vi.fn(), }); render( @@ -52,12 +48,9 @@ describe("AuthGate", () => { it("calls loginWithRedirect when unauthenticated", () => { const loginWithRedirect = vi.fn(); vi.mocked(useAuth0).mockReturnValue({ - isLoading: false, + ...auth0TestDefaults, isAuthenticated: false, - error: null, loginWithRedirect, - getAccessTokenSilently: vi.fn(), - logout: vi.fn(), }); render( @@ -69,23 +62,4 @@ describe("AuthGate", () => { expect(screen.queryByText("App Content")).not.toBeInTheDocument(); expect(loginWithRedirect).toHaveBeenCalled(); }); - - it("renders children when authenticated", () => { - vi.mocked(useAuth0).mockReturnValue({ - isLoading: false, - isAuthenticated: true, - error: null, - loginWithRedirect: vi.fn(), - getAccessTokenSilently: vi.fn(), - logout: vi.fn(), - }); - - render( - -
App Content
-
, - ); - - expect(screen.getByText("App Content")).toBeInTheDocument(); - }); }); diff --git a/deployments/stitch-frontend/src/auth/api.js b/deployments/stitch-frontend/src/auth/api.js index 2c03d06..80ba100 100644 --- a/deployments/stitch-frontend/src/auth/api.js +++ b/deployments/stitch-frontend/src/auth/api.js @@ -1,5 +1,14 @@ 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({ 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..32215f6 --- /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({ + logoutParams: { returnTo: window.location.origin }, + }); + }); +}); diff --git a/deployments/stitch-frontend/src/config/env.js b/deployments/stitch-frontend/src/config/env.js index 3af620c..6adef6c 100644 --- a/deployments/stitch-frontend/src/config/env.js +++ b/deployments/stitch-frontend/src/config/env.js @@ -24,6 +24,7 @@ function loadConfig() { }); } -const config = loadConfig(); +// Optional named export for `import { config } from "./config/env.js"` +export const config = loadConfig(); export default config; diff --git a/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js index 01d7415..32d0398 100644 --- a/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js +++ b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js @@ -2,6 +2,17 @@ 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); 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: { From 150103f0c28375969be47e2b7427a0c2d88ba5fd Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 20:11:57 -0700 Subject: [PATCH 14/16] chore: prettier formatting --- deployments/stitch-frontend/src/config/env.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/deployments/stitch-frontend/src/config/env.test.js b/deployments/stitch-frontend/src/config/env.test.js index 1be3216..3e45544 100644 --- a/deployments/stitch-frontend/src/config/env.test.js +++ b/deployments/stitch-frontend/src/config/env.test.js @@ -24,9 +24,7 @@ describe("config/env", () => { vi.stubEnv("VITE_AUTH0_CLIENT_ID", ""); vi.stubEnv("VITE_AUTH0_AUDIENCE", ""); - await expect(() => import("./env.js")).rejects.toThrow( - "VITE_AUTH0_DOMAIN", - ); + await expect(() => import("./env.js")).rejects.toThrow("VITE_AUTH0_DOMAIN"); }); it("uses default API URL when VITE_API_URL is unset", async () => { From 29035e24ff35814dd254d9645c753e49b95fb52c Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Sat, 21 Feb 2026 10:59:45 -0700 Subject: [PATCH 15/16] feat(frontend): add LoginPage component, replace auto-redirect in AuthGate --- .../stitch-frontend/src/auth/AuthGate.jsx | 12 ++----- .../src/auth/AuthGate.test.jsx | 13 +++----- .../src/components/LoginPage.jsx | 22 +++++++++++++ .../src/components/LoginPage.test.jsx | 33 +++++++++++++++++++ 4 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 deployments/stitch-frontend/src/components/LoginPage.jsx create mode 100644 deployments/stitch-frontend/src/components/LoginPage.test.jsx diff --git a/deployments/stitch-frontend/src/auth/AuthGate.jsx b/deployments/stitch-frontend/src/auth/AuthGate.jsx index 9026d6b..19cefd0 100644 --- a/deployments/stitch-frontend/src/auth/AuthGate.jsx +++ b/deployments/stitch-frontend/src/auth/AuthGate.jsx @@ -1,14 +1,8 @@ -import { useEffect } from "react"; import { useAuth0 } from "@auth0/auth0-react"; +import LoginPage from "../components/LoginPage"; export default function AuthGate({ children }) { - const { isLoading, isAuthenticated, error, loginWithRedirect } = useAuth0(); - - useEffect(() => { - if (!isLoading && !isAuthenticated && !error) { - loginWithRedirect(); - } - }, [isLoading, isAuthenticated, error, loginWithRedirect]); + const { isLoading, isAuthenticated, error } = useAuth0(); if (isLoading) { return ( @@ -29,7 +23,7 @@ export default function AuthGate({ children }) { } if (!isAuthenticated) { - return null; + return ; } return children; diff --git a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx index 5d3cde6..7b39d3f 100644 --- a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx +++ b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx @@ -20,16 +20,13 @@ describe("AuthGate", () => { expect(screen.getByText("Loading...")).toBeInTheDocument(); expect(screen.queryByText("App Content")).not.toBeInTheDocument(); - expect(vi.mocked(useAuth0)().loginWithRedirect).not.toHaveBeenCalled(); }); it("shows error message when auth fails", () => { - const loginWithRedirect = vi.fn(); vi.mocked(useAuth0).mockReturnValue({ ...auth0TestDefaults, isAuthenticated: false, error: new Error("Something went wrong"), - loginWithRedirect, }); render( @@ -42,15 +39,12 @@ describe("AuthGate", () => { screen.getByText("Authentication error: Something went wrong"), ).toBeInTheDocument(); expect(screen.queryByText("App Content")).not.toBeInTheDocument(); - expect(loginWithRedirect).not.toHaveBeenCalled(); }); - it("calls loginWithRedirect when unauthenticated", () => { - const loginWithRedirect = vi.fn(); + it("renders LoginPage when unauthenticated", () => { vi.mocked(useAuth0).mockReturnValue({ ...auth0TestDefaults, isAuthenticated: false, - loginWithRedirect, }); render( @@ -59,7 +53,10 @@ describe("AuthGate", () => { , ); + expect(screen.getByText("Stitch")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /log in to continue/i }), + ).toBeInTheDocument(); expect(screen.queryByText("App Content")).not.toBeInTheDocument(); - expect(loginWithRedirect).toHaveBeenCalled(); }); }); 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(); + }); +}); From 1cb6d1ade4fb2008275f0000912eb663c87cf245 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Sat, 21 Feb 2026 12:01:29 -0700 Subject: [PATCH 16/16] fix(frontend): use openUrl for logout redirect, bypass Auth0 /v2/logout --- deployments/stitch-frontend/src/components/LogoutButton.jsx | 4 +++- .../stitch-frontend/src/components/LogoutButton.test.jsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/deployments/stitch-frontend/src/components/LogoutButton.jsx b/deployments/stitch-frontend/src/components/LogoutButton.jsx index 4fea9a6..4942842 100644 --- a/deployments/stitch-frontend/src/components/LogoutButton.jsx +++ b/deployments/stitch-frontend/src/components/LogoutButton.jsx @@ -5,7 +5,9 @@ export const LogoutButton = () => { const { isAuthenticated, logout: authLogout } = useAuth0(); const logout = () => - authLogout({ logoutParams: { returnTo: window.location.origin } }); + 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 index 32215f6..197ce5a 100644 --- a/deployments/stitch-frontend/src/components/LogoutButton.test.jsx +++ b/deployments/stitch-frontend/src/components/LogoutButton.test.jsx @@ -32,7 +32,7 @@ describe("LogoutButton", () => { await user.click(screen.getByRole("button", { name: /log out/i })); expect(logout).toHaveBeenCalledWith({ - logoutParams: { returnTo: window.location.origin }, + openUrl: expect.any(Function), }); }); });