From 678fce1fac98bdbb6c85f5fec9ec8ce71c1feea8 Mon Sep 17 00:00:00 2001 From: Amine Saboni Date: Sun, 22 Feb 2026 20:18:57 +0100 Subject: [PATCH 1/3] fix(front): add public project encryption key & ci update feat(api): add pre ping to activate sql connexion fix: change caddy configuration fix: add tests, mocks, lint fix: experiment id parsing, add components tests fix: fix pnpm version fix: add mock service fix: fix build folfder fix: remove pnpm fix: add project encryption & ci update --- .github/workflows/deploy.yml | 17 ++++--- webapp/e2e/landing.spec.ts | 6 +++ webapp/src/api/experiments.ts | 9 +++- webapp/src/api/mock/handlers.ts | 10 ---- webapp/src/api/organizations.ts | 3 +- .../components/createOrganizationModal.tsx | 7 ++- webapp/src/components/createProjectModal.tsx | 7 ++- .../src/components/share-project-button.tsx | 38 +++++++------- webapp/src/pages/OrgDashboardPage.tsx | 3 ++ webapp/src/pages/ProjectsPage.tsx | 6 ++- webapp/src/pages/PublicProjectPage.tsx | 48 ++++++++++++++---- webapp/src/vite-env.d.ts | 1 + webapp/tests/api/mock/handlers.test.ts | 28 +---------- .../components/share-project-button.test.tsx | 50 +++++++++++++++---- 14 files changed, 144 insertions(+), 89 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 39c905341..391152009 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -41,14 +41,17 @@ jobs: ./clever-tools-latest_linux/clever link $CLEVER_APP_ID ./clever-tools-latest_linux/clever deploy -f - - name: Deploy frontend to Clever Cloud (PROD) + # The frontend used to be a Next.js Node.js app on Clever; it's now a + # Vite-built SPA served by Clever's static (Caddy) engine. The deploy + # command is identical (git push), only the target app changes — + # `FE_CLEVER_APP_ID_PROD` must be pointed at the new static app and + # `FE_CLEVER_APP_ALIAS_PROD` set to that app's alias (no underscores; + # see https://github.com/CleverCloud/clever-tools for the alias-escape + # workaround required by `clever deploy -a `). + - name: Deploy frontend (static SPA) to Clever Cloud (PROD) env: CLEVER_APP_ID: ${{ secrets.FE_CLEVER_APP_ID_PROD }} - APP_NAME: cc_dashboard_prod + CLEVER_APP_ALIAS: ${{ secrets.FE_CLEVER_APP_ALIAS_PROD }} run: | - echo $CLEVER_APP_ID - echo $APP_NAME ./clever-tools-latest_linux/clever link $CLEVER_APP_ID - # As the clever tools CLI aliasing is escaping _ character, a temporary hard-coded value is needed - # waiting for a fix from Clever - ./clever-tools-latest_linux/clever deploy -f -a ccdashboardprod --quiet + ./clever-tools-latest_linux/clever deploy -f -a $CLEVER_APP_ALIAS --quiet diff --git a/webapp/e2e/landing.spec.ts b/webapp/e2e/landing.spec.ts index 273db3b2a..9b849ccf7 100644 --- a/webapp/e2e/landing.spec.ts +++ b/webapp/e2e/landing.spec.ts @@ -10,6 +10,12 @@ test.describe("Landing page (mock mode)", () => { page.getByRole("heading", { name: /welcome to code carbon/i }), ).toBeVisible(); + await expect( + page.getByRole("link", { + name: /sign in or create an account/i, + }), + ).toBeVisible(); + // In mock mode the real-login button is hidden — there is no real // OAuth backend in this build. await expect(page.getByTestId("real-login")).toHaveCount(0); diff --git a/webapp/src/api/experiments.ts b/webapp/src/api/experiments.ts index f00cb8e14..1843fd324 100644 --- a/webapp/src/api/experiments.ts +++ b/webapp/src/api/experiments.ts @@ -31,7 +31,14 @@ export async function getExperiments(projectId: string): Promise { (e) => typeof e.id === "string" && e.id.length > 0, ); } catch (error) { - console.error("[getExperiments] failed", error); + console.error("[getExperiments] failed", error);return result.map((experiment) => ({ + id: experiment.id, + name: experiment.name, + description: experiment.description, + project_id: experiment.project_id, + timestamp: experiment.timestamp, + })); + } catch { return []; } } diff --git a/webapp/src/api/mock/handlers.ts b/webapp/src/api/mock/handlers.ts index 64b059016..f2d49b18f 100644 --- a/webapp/src/api/mock/handlers.ts +++ b/webapp/src/api/mock/handlers.ts @@ -101,16 +101,6 @@ const handlers: Handler[] = [ } if (method === "DELETE") return noContent(); } - const publicMatch = pathname.match(/^\/projects\/public\/([^/]+)$/); - if (method === "GET" && publicMatch) { - // Treat the encrypted_id as the project id for mock purposes. - const project = MOCK.project.byId[publicMatch[1]]; - return project ? ok(project) : notFound(); - } - const shareLink = pathname.match(/^\/projects\/([^/]+)\/share-link$/); - if (method === "GET" && shareLink) { - return ok({ encrypted_id: shareLink[1] }); - } return undefined; }, diff --git a/webapp/src/api/organizations.ts b/webapp/src/api/organizations.ts index d72f949c4..cb989d267 100644 --- a/webapp/src/api/organizations.ts +++ b/webapp/src/api/organizations.ts @@ -28,8 +28,7 @@ export async function getOrganizationEmissionsByProject( export async function getOrganizations(): Promise { try { return await fetchApi("/organizations", OrganizationSchema.array()); - } catch (error) { - console.error("[getOrganizations] failed", error); + return []; } } diff --git a/webapp/src/components/createOrganizationModal.tsx b/webapp/src/components/createOrganizationModal.tsx index 2482d5661..2e1270eb7 100644 --- a/webapp/src/components/createOrganizationModal.tsx +++ b/webapp/src/components/createOrganizationModal.tsx @@ -91,7 +91,7 @@ const CreateOrganizationModal: React.FC = ({ setFormData({ @@ -102,6 +102,11 @@ const CreateOrganizationModal: React.FC = ({ placeholder="Organization Name" /> +
+ +
+
+ +
diff --git a/webapp/src/pages/ProjectsPage.tsx b/webapp/src/pages/ProjectsPage.tsx index 539431e40..55a226146 100644 --- a/webapp/src/pages/ProjectsPage.tsx +++ b/webapp/src/pages/ProjectsPage.tsx @@ -108,7 +108,11 @@ export default function ProjectsPage() { {projectList && projectList - .sort((a, b) => + .sort((a, b) =>a.name + .toLowerCase() + .localeCompare( + b.name.toLowerCase(), + ), a.name .toLowerCase() .localeCompare( diff --git a/webapp/src/pages/PublicProjectPage.tsx b/webapp/src/pages/PublicProjectPage.tsx index 93cbca39a..af9c6062b 100644 --- a/webapp/src/pages/PublicProjectPage.tsx +++ b/webapp/src/pages/PublicProjectPage.tsx @@ -19,6 +19,7 @@ import Loader from "@/components/loader"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { AlertCircle } from "lucide-react"; import { getDefaultDateRange } from "@/helpers/date-utils"; +import { decryptProjectId } from "@/utils/crypto"; export default function PublicProjectPage() { const { projectId: encryptedId } = useParams<{ projectId: string }>(); @@ -71,28 +72,55 @@ export default function PublicProjectPage() { } }, [projectId]); - // Decrypt the project ID via the backend + // Decrypt the project ID client-side. The encrypted token is computed + // with the same key in `ShareProjectButton`, so decryption is purely + // local — no backend round-trip required. useEffect(() => { const decrypt = async () => { + if (!encryptedId) return; try { - setIsLoading(true); - const result = await fetchApi( - `/projects/public/${encryptedId}`, - ProjectSchema, - ); - setProjectId(result.id); - setProject(result); - } catch { + const decryptedId = await decryptProjectId(encryptedId); + setProjectId(decryptedId); + } catch (err) { + console.error("Failed to decrypt project ID:", err); setError( "Invalid project link or the project no longer exists.", ); - } finally { setIsLoading(false); } }; decrypt(); }, [encryptedId]); + // Once we have the real project id, fetch the project. The backend + // already serves public projects through the regular endpoint without + // authentication. + useEffect(() => { + const fetchProjectData = async () => { + if (!projectId || project) return; + try { + setIsLoading(true); + const projectData = await fetchApi( + `/projects/${projectId}`, + ProjectSchema, + ); + if (!projectData.public) { + setError( + "This project is not available for public viewing.", + ); + return; + } + setProject(projectData); + } catch (err) { + console.error("Error fetching project:", err); + setError("Failed to load project data."); + } finally { + setIsLoading(false); + } + }; + fetchProjectData(); + }, [projectId, project]); + useEffect(() => { if (projectId && project) { refreshExperimentList(); diff --git a/webapp/src/vite-env.d.ts b/webapp/src/vite-env.d.ts index c6b0c9b0d..ffbafe041 100644 --- a/webapp/src/vite-env.d.ts +++ b/webapp/src/vite-env.d.ts @@ -4,6 +4,7 @@ interface ImportMetaEnv { readonly VITE_API_URL: string; readonly VITE_BASE_URL: string; readonly VITE_USE_MOCK_DATA?: string; + readonly VITE_PROJECT_ENCRYPTION_KEY?: string; } interface ImportMeta { diff --git a/webapp/tests/api/mock/handlers.test.ts b/webapp/tests/api/mock/handlers.test.ts index c82ed6d41..d823e3409 100644 --- a/webapp/tests/api/mock/handlers.test.ts +++ b/webapp/tests/api/mock/handlers.test.ts @@ -29,23 +29,8 @@ describe("resolveMock — organizations", () => { expect((r.body as { id: string }).id).toBe(ID.org); }); - it("returns 404 for an unknown organization", () => { - const r = resolveMock(url("/organizations/does-not-exist"), "GET"); expect(r.status).toBe(404); - }); - - it("returns users for the mock organization", () => { - const r = resolveMock(url(`/organizations/${ID.org}/users`), "GET"); - expect(r.status).toBe(200); - const users = r.body as Array<{ id: string }>; - expect(users.map((u) => u.id)).toContain(ID.users.admin); - expect(users.map((u) => u.id)).toContain(ID.users.member); - }); - - it("returns the org sums report", () => { - const r = resolveMock(url(`/organizations/${ID.org}/sums`), "GET"); - expect(r.status).toBe(200); - expect((r.body as { name: string }).name).toBe("Mock Organization"); + ); }); it("synthesizes an added user on POST /add-user", () => { @@ -76,17 +61,6 @@ describe("resolveMock — projects", () => { expect((r.body as { id: string }).id).toBe(ID.projects.training); }); - it("returns a share-link encrypted_id", () => { - const r = resolveMock( - url(`/projects/${ID.projects.inference}/share-link`), - "GET", - ); - expect(r.status).toBe(200); - expect((r.body as { encrypted_id: string }).encrypted_id).toBe( - ID.projects.inference, - ); - }); - it("204s on project deletion", () => { const r = resolveMock( url(`/projects/${ID.projects.training}`), diff --git a/webapp/tests/components/share-project-button.test.tsx b/webapp/tests/components/share-project-button.test.tsx index 8ff40e942..339c27dac 100644 --- a/webapp/tests/components/share-project-button.test.tsx +++ b/webapp/tests/components/share-project-button.test.tsx @@ -1,19 +1,27 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import ShareProjectButton from "@/components/share-project-button"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const encryptProjectIdMock = vi.hoisted(() => vi.fn()); +vi.mock("@/utils/crypto", () => ({ + encryptProjectId: encryptProjectIdMock, +})); -const originalFetch = globalThis.fetch; +const copyMock = vi.hoisted(() => vi.fn(() => true)); +vi.mock("copy-to-clipboard", () => ({ + default: copyMock, +})); + +import ShareProjectButton from "@/components/share-project-button"; beforeEach(() => { - globalThis.fetch = vi.fn(async () => ({ - ok: true, - status: 200, - json: async () => ({ encrypted_id: "enc-123" }), - })) as unknown as typeof fetch; + encryptProjectIdMock.mockReset(); + encryptProjectIdMock.mockResolvedValue("enc-token-xyz"); + copyMock.mockReset(); + copyMock.mockReturnValue(true); }); afterEach(() => { - globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); @@ -23,6 +31,7 @@ describe("ShareProjectButton", () => { , ); expect(container.textContent).toBe(""); + expect(encryptProjectIdMock).not.toHaveBeenCalled(); }); it("renders the share trigger for public projects", () => { @@ -31,4 +40,27 @@ describe("ShareProjectButton", () => { screen.getByRole("button", { name: /share project/i }), ).toBeInTheDocument(); }); + + it("computes the encrypted id client-side when the popover opens", async () => { + render(); + + await userEvent.click( + screen.getByRole("button", { name: /share project/i }), + ); + + await waitFor(() => + expect(encryptProjectIdMock).toHaveBeenCalledWith("p1"), + ); + + // The encrypted token shows up in the share-link input. + const input = await screen.findByDisplayValue( + /\/public\/projects\/enc-token-xyz$/, + ); + expect(input).toBeInTheDocument(); + }); + + it("does not call the encryptor before the popover is opened", () => { + render(); + expect(encryptProjectIdMock).not.toHaveBeenCalled(); + }); }); From 2d8f1ef827188a202ea497bd439b45240d2af8eb Mon Sep 17 00:00:00 2001 From: Amine Saboni Date: Wed, 27 May 2026 17:33:03 +0200 Subject: [PATCH 2/3] fix: rebase --- webapp/e2e/landing.spec.ts | 8 +- webapp/src/api/experiments.ts | 9 +- webapp/src/api/organizations.ts | 3 +- .../components/createOrganizationModal.tsx | 2 +- webapp/src/components/createProjectModal.tsx | 2 +- webapp/src/pages/ProjectsPage.tsx | 6 +- webapp/src/utils/crypto.ts | 152 ++++++++++++++++++ webapp/tests/api/mock/handlers.test.ts | 17 +- 8 files changed, 175 insertions(+), 24 deletions(-) create mode 100644 webapp/src/utils/crypto.ts diff --git a/webapp/e2e/landing.spec.ts b/webapp/e2e/landing.spec.ts index 9b849ccf7..6947dee84 100644 --- a/webapp/e2e/landing.spec.ts +++ b/webapp/e2e/landing.spec.ts @@ -10,14 +10,8 @@ test.describe("Landing page (mock mode)", () => { page.getByRole("heading", { name: /welcome to code carbon/i }), ).toBeVisible(); - await expect( - page.getByRole("link", { - name: /sign in or create an account/i, - }), - ).toBeVisible(); - // In mock mode the real-login button is hidden — there is no real - // OAuth backend in this build. + // OAuth backend in this build. Only the mock button is rendered. await expect(page.getByTestId("real-login")).toHaveCount(0); await expect(page.getByTestId("mock-login")).toBeVisible(); }); diff --git a/webapp/src/api/experiments.ts b/webapp/src/api/experiments.ts index 1843fd324..f00cb8e14 100644 --- a/webapp/src/api/experiments.ts +++ b/webapp/src/api/experiments.ts @@ -31,14 +31,7 @@ export async function getExperiments(projectId: string): Promise { (e) => typeof e.id === "string" && e.id.length > 0, ); } catch (error) { - console.error("[getExperiments] failed", error);return result.map((experiment) => ({ - id: experiment.id, - name: experiment.name, - description: experiment.description, - project_id: experiment.project_id, - timestamp: experiment.timestamp, - })); - } catch { + console.error("[getExperiments] failed", error); return []; } } diff --git a/webapp/src/api/organizations.ts b/webapp/src/api/organizations.ts index cb989d267..d72f949c4 100644 --- a/webapp/src/api/organizations.ts +++ b/webapp/src/api/organizations.ts @@ -28,7 +28,8 @@ export async function getOrganizationEmissionsByProject( export async function getOrganizations(): Promise { try { return await fetchApi("/organizations", OrganizationSchema.array()); - + } catch (error) { + console.error("[getOrganizations] failed", error); return []; } } diff --git a/webapp/src/components/createOrganizationModal.tsx b/webapp/src/components/createOrganizationModal.tsx index 2e1270eb7..41f385454 100644 --- a/webapp/src/components/createOrganizationModal.tsx +++ b/webapp/src/components/createOrganizationModal.tsx @@ -91,7 +91,7 @@ const CreateOrganizationModal: React.FC = ({ setFormData({ diff --git a/webapp/src/components/createProjectModal.tsx b/webapp/src/components/createProjectModal.tsx index 3671d100e..5f533808d 100644 --- a/webapp/src/components/createProjectModal.tsx +++ b/webapp/src/components/createProjectModal.tsx @@ -87,7 +87,7 @@ const CreateProjectModal: React.FC = ({ setFormData({ diff --git a/webapp/src/pages/ProjectsPage.tsx b/webapp/src/pages/ProjectsPage.tsx index 55a226146..539431e40 100644 --- a/webapp/src/pages/ProjectsPage.tsx +++ b/webapp/src/pages/ProjectsPage.tsx @@ -108,11 +108,7 @@ export default function ProjectsPage() { {projectList && projectList - .sort((a, b) =>a.name - .toLowerCase() - .localeCompare( - b.name.toLowerCase(), - ), + .sort((a, b) => a.name .toLowerCase() .localeCompare( diff --git a/webapp/src/utils/crypto.ts b/webapp/src/utils/crypto.ts new file mode 100644 index 000000000..066867831 --- /dev/null +++ b/webapp/src/utils/crypto.ts @@ -0,0 +1,152 @@ +// Client-side encryption for public project sharing links. +// +// Port of the original Node `crypto` implementation (see git history on +// master, pre-React-migration) to the browser's Web Crypto API. The +// algorithm is: +// +// 1. Derive a deterministic 16-byte IV per project id: +// IV = HMAC-SHA256(secret, projectId).slice(0, 16) +// This guarantees the same project id always encrypts to the same +// string — useful for caching and clean URLs. +// 2. Pad the secret to exactly 32 ASCII bytes: +// aesKey = utf8(secret.substring(0, 32).padEnd(32, "0")) +// 3. AES-256-CBC encrypt the projectId. +// 4. Output: base64url(IV ‖ ciphertext) +// +// `VITE_PROJECT_ENCRYPTION_KEY` is exposed to the browser via the bundle. +// This is *obfuscation*, not real security: anyone with the bundle can +// recover real project ids from encrypted links. That trade-off is +// inherent to a client-only architecture — the threat model is "make ids +// non-guessable in URLs", not "hide ids from a determined attacker". + +const TEXT_ENCODER = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); + +function getSecret(): string { + const secret = import.meta.env.VITE_PROJECT_ENCRYPTION_KEY; + if (!secret || typeof secret !== "string") { + throw new Error( + "VITE_PROJECT_ENCRYPTION_KEY is not set. " + + "Add it to webapp/.env (see .env.example) to enable share links.", + ); + } + return secret; +} + +function getSubtle(): SubtleCrypto { + const subtle = + typeof globalThis.crypto !== "undefined" + ? globalThis.crypto.subtle + : undefined; + if (!subtle) { + throw new Error( + "Web Crypto (crypto.subtle) is unavailable in this environment.", + ); + } + return subtle; +} + +// Returns a freshly-allocated Uint8Array backed by a non-shared +// ArrayBuffer. Required by `SubtleCrypto.encrypt`'s `iv` field under +// TS 5.7+ where ArrayBufferView is generic over its buffer type. +function copyBytes(view: Uint8Array): Uint8Array { + const buffer = new ArrayBuffer(view.byteLength); + const out = new Uint8Array(buffer); + out.set(view); + return out; +} + +async function deriveIv( + projectId: string, + secret: string, +): Promise> { + const subtle = getSubtle(); + const key = await subtle.importKey( + "raw", + TEXT_ENCODER.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await subtle.sign( + "HMAC", + key, + TEXT_ENCODER.encode(projectId), + ); + return copyBytes(new Uint8Array(signature).subarray(0, 16)); +} + +async function deriveAesKey(secret: string): Promise { + const subtle = getSubtle(); + const padded = secret.substring(0, 32).padEnd(32, "0"); + const rawKey = TEXT_ENCODER.encode(padded).slice(0, 32); + return subtle.importKey("raw", rawKey, { name: "AES-CBC" }, false, [ + "encrypt", + "decrypt", + ]); +} + +function toBase64Url(bytes: Uint8Array): string { + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function fromBase64Url(value: string): Uint8Array { + const padded = value + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(value.length + ((4 - (value.length % 4)) % 4), "="); + const bin = atob(padded); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +/** + * Encrypt a project id into a URL-safe sharing token. + * Deterministic: the same project id always produces the same output. + */ +export async function encryptProjectId(projectId: string): Promise { + const secret = getSecret(); + const subtle = getSubtle(); + + const iv = await deriveIv(projectId, secret); + const key = await deriveAesKey(secret); + + const ciphertext = await subtle.encrypt( + { name: "AES-CBC", iv }, + key, + TEXT_ENCODER.encode(projectId), + ); + const ciphertextBytes = new Uint8Array(ciphertext); + + const combined = new Uint8Array(iv.byteLength + ciphertextBytes.byteLength); + combined.set(iv, 0); + combined.set(ciphertextBytes, iv.byteLength); + return toBase64Url(combined); +} + +/** + * Decrypt a sharing token back into the original project id. + * Throws if the token is malformed or the secret is wrong. + */ +export async function decryptProjectId(encrypted: string): Promise { + const secret = getSecret(); + const subtle = getSubtle(); + + const combined = fromBase64Url(encrypted); + if (combined.byteLength <= 16) { + throw new Error("Invalid sharing token"); + } + const iv = copyBytes(combined.subarray(0, 16)); + const ciphertext = copyBytes(combined.subarray(16)); + + const key = await deriveAesKey(secret); + const plaintext = await subtle.decrypt( + { name: "AES-CBC", iv }, + key, + ciphertext, + ); + return TEXT_DECODER.decode(plaintext); +} diff --git a/webapp/tests/api/mock/handlers.test.ts b/webapp/tests/api/mock/handlers.test.ts index d823e3409..b0dd2c4ca 100644 --- a/webapp/tests/api/mock/handlers.test.ts +++ b/webapp/tests/api/mock/handlers.test.ts @@ -29,8 +29,23 @@ describe("resolveMock — organizations", () => { expect((r.body as { id: string }).id).toBe(ID.org); }); + it("returns 404 for an unknown organization", () => { + const r = resolveMock(url("/organizations/does-not-exist"), "GET"); expect(r.status).toBe(404); - ); + }); + + it("returns users for the mock organization", () => { + const r = resolveMock(url(`/organizations/${ID.org}/users`), "GET"); + expect(r.status).toBe(200); + const users = r.body as Array<{ id: string }>; + expect(users.map((u) => u.id)).toContain(ID.users.admin); + expect(users.map((u) => u.id)).toContain(ID.users.member); + }); + + it("returns the org sums report", () => { + const r = resolveMock(url(`/organizations/${ID.org}/sums`), "GET"); + expect(r.status).toBe(200); + expect((r.body as { name: string }).name).toBe("Mock Organization"); }); it("synthesizes an added user on POST /add-user", () => { From f6ece25b536f648e9dc4e2252b22b636e0bfc37c Mon Sep 17 00:00:00 2001 From: Amine Saboni Date: Wed, 27 May 2026 18:44:15 +0200 Subject: [PATCH 3/3] fix: rebase --- webapp/.env.development | 15 ++ webapp/.env.example | 24 +++ webapp/src/api/experiments.ts | 16 +- webapp/src/api/mock/index.ts | 32 ++- webapp/src/api/runs.ts | 33 +-- webapp/src/api/schemas.ts | 49 ++--- .../components/createOrganizationModal.tsx | 2 +- webapp/src/components/createProjectModal.tsx | 2 +- webapp/src/components/navbar.tsx | 10 +- .../src/components/project-dashboard-base.tsx | 125 ++++++++---- webapp/src/components/runs-scatter-chart.tsx | 9 +- webapp/src/pages/OrgDashboardPage.tsx | 2 +- webapp/src/pages/ProjectDashboardPage.tsx | 191 ++++++------------ webapp/tests/api/schemas.test.ts | 21 ++ .../project-dashboard-base.test.tsx | 30 ++- 15 files changed, 301 insertions(+), 260 deletions(-) create mode 100644 webapp/.env.development create mode 100644 webapp/.env.example diff --git a/webapp/.env.development b/webapp/.env.development new file mode 100644 index 000000000..6c316dc40 --- /dev/null +++ b/webapp/.env.development @@ -0,0 +1,15 @@ +# Local dev defaults. Loaded automatically by Vite on `npm run dev`. +# Override per-developer with `.env.development.local` (gitignored). + +# Empty VITE_API_URL → the mock fetch interceptor catches every +# same-origin /api request and serves from src/api/mock/data.ts. +# Set this to a real API base (e.g. http://localhost:8008) and unset +# VITE_USE_MOCK_DATA to talk to a local carbonserver instead. +VITE_API_URL= +VITE_BASE_URL=http://localhost:3000 + +VITE_USE_MOCK_DATA=true + +# Used by share-project-button to produce shareable public links. +# Any 32-byte (256-bit) hex string works for dev. +VITE_PROJECT_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef diff --git a/webapp/.env.example b/webapp/.env.example new file mode 100644 index 000000000..a58959bd0 --- /dev/null +++ b/webapp/.env.example @@ -0,0 +1,24 @@ +# Example webapp env. Copy to `.env.development.local` (gitignored) to +# override local dev defaults from `.env.development`, or to +# `.env.production.local` for production builds. + +# Base URL of the carbonserver API. Leave empty in mock mode. +VITE_API_URL=http://localhost:8008 + +# Public origin of this webapp. Used to build OAuth redirect URLs and +# share links. +VITE_BASE_URL=http://localhost:3000 + +# Toggle the in-browser mock service (src/api/mock/). When "true", every +# same-origin fetch is resolved from synthetic data and no carbonserver +# is required. +VITE_USE_MOCK_DATA=false + +# AES key used to encrypt project ids in shareable public links. Must be +# 64 hex chars (256 bits). Keep stable across the fleet — rotating it +# invalidates existing links. +VITE_PROJECT_ENCRYPTION_KEY= + +# Fief profile base URL — surfaces a "Profile" item in the navbar that +# links to the IdP's account page. Leave empty to hide it. +VITE_FIEF_BASE_URL= diff --git a/webapp/src/api/experiments.ts b/webapp/src/api/experiments.ts index f00cb8e14..206d45696 100644 --- a/webapp/src/api/experiments.ts +++ b/webapp/src/api/experiments.ts @@ -19,17 +19,10 @@ export async function createExperiment( export async function getExperiments(projectId: string): Promise { try { - const result = await fetchApi( + return await fetchApi( `/projects/${projectId}/experiments`, ExperimentSchema.array(), ); - // Drop experiments that somehow lack a usable id — they cannot be - // selected, fetched, or rendered downstream. Keeping them would - // surface as unselectable rows whose click silently clears the - // selection. - return result.filter( - (e) => typeof e.id === "string" && e.id.length > 0, - ); } catch (error) { console.error("[getExperiments] failed", error); return []; @@ -54,12 +47,7 @@ export async function getProjectEmissionsByExperiment( } try { - const result = await fetchApi(url, ExperimentReportSchema.array()); - return result.filter( - (r) => - typeof r.experiment_id === "string" && - r.experiment_id.length > 0, - ); + return await fetchApi(url, ExperimentReportSchema.array()); } catch (error) { console.error("[getProjectEmissionsByExperiment] failed", error); return []; diff --git a/webapp/src/api/mock/index.ts b/webapp/src/api/mock/index.ts index c04f6f09e..94bfbd9cb 100644 --- a/webapp/src/api/mock/index.ts +++ b/webapp/src/api/mock/index.ts @@ -14,7 +14,7 @@ export function installMockFetch(): void { const apiBase = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, ""); const apiPathPrefix = apiBase - ? new URL(apiBase).pathname.replace(/\/$/, "") + ? (safeUrl(apiBase)?.pathname.replace(/\/$/, "") ?? "") : ""; const realFetch = window.fetch.bind(window); @@ -29,16 +29,26 @@ export function installMockFetch(): void { ? input.toString() : input.url; - const isApiCall = apiBase ? rawUrl.startsWith(apiBase) : false; + // With an explicit VITE_API_URL we only intercept requests aimed at + // it. With no VITE_API_URL (the default dev/mock setup), every + // same-origin fetch is treated as an API call — Vite's static + // assets are loaded via