Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
VITE_AUTH0_DOMAIN=
VITE_AUTH0_CLIENT_ID=
VITE_AUTH0_AUDIENCE=
VITE_API_URL=
98 changes: 98 additions & 0 deletions deployments/stitch-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions deployments/stitch-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions deployments/stitch-frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import ResourcesView from "./components/ResourcesView";
import ResourceView from "./components/ResourceView";
import { LogoutButton } from "./components/LogoutButton";

function App() {
return (
<div className="min-h-screen w-screen bg-gray-100 p-8">
<div className="max-w-4xl mx-auto flex justify-end mb-4">
<LogoutButton />
</div>
<ResourcesView endpoint="/api/v1/resources" />
<ResourceView className="mt-24" endpoint="/api/v1/resources/{id}" />
</div>
Expand Down
30 changes: 30 additions & 0 deletions deployments/stitch-frontend/src/auth/AuthGate.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen flex items-center justify-center">
<p className="text-gray-500 text-lg">Loading...</p>
</div>
);
}

if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-red-600 text-lg">
Authentication error: {error.message}
</p>
</div>
);
}

if (!isAuthenticated) {
return <LoginPage />;
}

return children;
}
62 changes: 62 additions & 0 deletions deployments/stitch-frontend/src/auth/AuthGate.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<AuthGate>
<div>App Content</div>
</AuthGate>,
);

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(
<AuthGate>
<div>App Content</div>
</AuthGate>,
);

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(
<AuthGate>
<div>App Content</div>
</AuthGate>,
);

expect(screen.getByText("Stitch")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /log in to continue/i }),
).toBeInTheDocument();
expect(screen.queryByText("App Content")).not.toBeInTheDocument();
});
});
21 changes: 21 additions & 0 deletions deployments/stitch-frontend/src/auth/api.js
Original file line number Diff line number Diff line change
@@ -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 });
};
}
68 changes: 68 additions & 0 deletions deployments/stitch-frontend/src/auth/api.test.js
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
22 changes: 22 additions & 0 deletions deployments/stitch-frontend/src/components/LoginPage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useAuth0 } from "@auth0/auth0-react";
import Button from "./Button";

export default function LoginPage() {
const { loginWithRedirect } = useAuth0();

return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center max-w-md px-6">
<h1 className="text-4xl font-bold text-gray-900 mb-2">Stitch</h1>
<p className="text-lg text-gray-500 mb-6">
Oil &amp; Gas Asset Data Platform
</p>
<p className="text-gray-600 mb-8">
Integrate diverse datasets, apply AI-driven enrichment with human
review, and deliver curated, trustworthy data.
</p>
<Button onClick={() => loginWithRedirect()}>Log in to continue</Button>
</div>
</div>
);
}
Loading