Skip to content

Commit e2f641c

Browse files
committed
fix: add project encryption & ci update
1 parent ef75c4f commit e2f641c

7 files changed

Lines changed: 108 additions & 67 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,17 @@ jobs:
4141
./clever-tools-latest_linux/clever link $CLEVER_APP_ID
4242
./clever-tools-latest_linux/clever deploy -f
4343
44-
- name: Deploy frontend to Clever Cloud (PROD)
44+
# The frontend used to be a Next.js Node.js app on Clever; it's now a
45+
# Vite-built SPA served by Clever's static (Caddy) engine. The deploy
46+
# command is identical (git push), only the target app changes —
47+
# `FE_CLEVER_APP_ID_PROD` must be pointed at the new static app and
48+
# `FE_CLEVER_APP_ALIAS_PROD` set to that app's alias (no underscores;
49+
# see https://github.com/CleverCloud/clever-tools for the alias-escape
50+
# workaround required by `clever deploy -a <alias>`).
51+
- name: Deploy frontend (static SPA) to Clever Cloud (PROD)
4552
env:
4653
CLEVER_APP_ID: ${{ secrets.FE_CLEVER_APP_ID_PROD }}
47-
APP_NAME: cc_dashboard_prod
54+
CLEVER_APP_ALIAS: ${{ secrets.FE_CLEVER_APP_ALIAS_PROD }}
4855
run: |
49-
echo $CLEVER_APP_ID
50-
echo $APP_NAME
5156
./clever-tools-latest_linux/clever link $CLEVER_APP_ID
52-
# As the clever tools CLI aliasing is escaping _ character, a temporary hard-coded value is needed
53-
# waiting for a fix from Clever
54-
./clever-tools-latest_linux/clever deploy -f -a ccdashboardprod --quiet
57+
./clever-tools-latest_linux/clever deploy -f -a $CLEVER_APP_ALIAS --quiet

webapp/src/api/mock/handlers.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,16 +101,6 @@ const handlers: Handler[] = [
101101
}
102102
if (method === "DELETE") return noContent();
103103
}
104-
const publicMatch = pathname.match(/^\/projects\/public\/([^/]+)$/);
105-
if (method === "GET" && publicMatch) {
106-
// Treat the encrypted_id as the project id for mock purposes.
107-
const project = MOCK.project.byId[publicMatch[1]];
108-
return project ? ok(project) : notFound();
109-
}
110-
const shareLink = pathname.match(/^\/projects\/([^/]+)\/share-link$/);
111-
if (method === "GET" && shareLink) {
112-
return ok({ encrypted_id: shareLink[1] });
113-
}
114104
return undefined;
115105
},
116106

webapp/src/components/share-project-button.tsx

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
import { Badge } from "@/components/ui/badge";
21
import { Button } from "@/components/ui/button";
32
import { Input } from "@/components/ui/input";
43
import {
54
Popover,
65
PopoverContent,
76
PopoverTrigger,
87
} from "@/components/ui/popover";
9-
import { fetchApi } from "@/api/client";
10-
import { z } from "zod";
8+
import { encryptProjectId } from "@/utils/crypto";
119
import copy from "copy-to-clipboard";
12-
import { CheckIcon, CopyIcon, LockIcon, Share2Icon } from "lucide-react";
10+
import { CheckIcon, CopyIcon, Share2Icon } from "lucide-react";
1311
import { useEffect, useRef, useState } from "react";
1412
import { toast } from "sonner";
1513

@@ -36,26 +34,26 @@ export default function ShareProjectButton({
3634
}, []);
3735

3836
useEffect(() => {
39-
const fetchEncryptedId = async () => {
40-
if (isPublic && projectId && isOpen && !encryptedId) {
41-
try {
42-
setIsLoading(true);
43-
const result = await fetchApi(
44-
`/projects/${projectId}/share-link`,
45-
z.object({ encrypted_id: z.string() }),
46-
);
47-
const encrypted = result.encrypted_id;
48-
setEncryptedId(encrypted);
49-
} catch (error) {
50-
console.error("Failed to encrypt project ID:", error);
37+
let cancelled = false;
38+
const computeEncryptedId = async () => {
39+
if (!(isPublic && projectId && isOpen && !encryptedId)) return;
40+
try {
41+
setIsLoading(true);
42+
const encrypted = await encryptProjectId(projectId);
43+
if (!cancelled) setEncryptedId(encrypted);
44+
} catch (error) {
45+
console.error("Failed to encrypt project ID:", error);
46+
if (!cancelled) {
5147
toast.error("Failed to generate secure sharing link");
52-
} finally {
53-
setIsLoading(false);
5448
}
49+
} finally {
50+
if (!cancelled) setIsLoading(false);
5551
}
5652
};
57-
58-
fetchEncryptedId();
53+
computeEncryptedId();
54+
return () => {
55+
cancelled = true;
56+
};
5957
}, [projectId, isPublic, isOpen, encryptedId]);
6058

6159
const publicUrl = encryptedId

webapp/src/pages/PublicProjectPage.tsx

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import Loader from "@/components/loader";
1919
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
2020
import { AlertCircle } from "lucide-react";
2121
import { getDefaultDateRange } from "@/helpers/date-utils";
22+
import { decryptProjectId } from "@/utils/crypto";
2223

2324
export default function PublicProjectPage() {
2425
const { projectId: encryptedId } = useParams<{ projectId: string }>();
@@ -71,28 +72,55 @@ export default function PublicProjectPage() {
7172
}
7273
}, [projectId]);
7374

74-
// Decrypt the project ID via the backend
75+
// Decrypt the project ID client-side. The encrypted token is computed
76+
// with the same key in `ShareProjectButton`, so decryption is purely
77+
// local — no backend round-trip required.
7578
useEffect(() => {
7679
const decrypt = async () => {
80+
if (!encryptedId) return;
7781
try {
78-
setIsLoading(true);
79-
const result = await fetchApi(
80-
`/projects/public/${encryptedId}`,
81-
ProjectSchema,
82-
);
83-
setProjectId(result.id);
84-
setProject(result);
85-
} catch {
82+
const decryptedId = await decryptProjectId(encryptedId);
83+
setProjectId(decryptedId);
84+
} catch (err) {
85+
console.error("Failed to decrypt project ID:", err);
8686
setError(
8787
"Invalid project link or the project no longer exists.",
8888
);
89-
} finally {
9089
setIsLoading(false);
9190
}
9291
};
9392
decrypt();
9493
}, [encryptedId]);
9594

95+
// Once we have the real project id, fetch the project. The backend
96+
// already serves public projects through the regular endpoint without
97+
// authentication.
98+
useEffect(() => {
99+
const fetchProjectData = async () => {
100+
if (!projectId || project) return;
101+
try {
102+
setIsLoading(true);
103+
const projectData = await fetchApi(
104+
`/projects/${projectId}`,
105+
ProjectSchema,
106+
);
107+
if (!projectData.public) {
108+
setError(
109+
"This project is not available for public viewing.",
110+
);
111+
return;
112+
}
113+
setProject(projectData);
114+
} catch (err) {
115+
console.error("Error fetching project:", err);
116+
setError("Failed to load project data.");
117+
} finally {
118+
setIsLoading(false);
119+
}
120+
};
121+
fetchProjectData();
122+
}, [projectId, project]);
123+
96124
useEffect(() => {
97125
if (projectId && project) {
98126
refreshExperimentList();

webapp/src/vite-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ interface ImportMetaEnv {
44
readonly VITE_API_URL: string;
55
readonly VITE_BASE_URL: string;
66
readonly VITE_USE_MOCK_DATA?: string;
7+
readonly VITE_PROJECT_ENCRYPTION_KEY?: string;
78
}
89

910
interface ImportMeta {

webapp/tests/api/mock/handlers.test.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,6 @@ describe("resolveMock — projects", () => {
7676
expect((r.body as { id: string }).id).toBe(ID.projects.training);
7777
});
7878

79-
it("returns a share-link encrypted_id", () => {
80-
const r = resolveMock(
81-
url(`/projects/${ID.projects.inference}/share-link`),
82-
"GET",
83-
);
84-
expect(r.status).toBe(200);
85-
expect((r.body as { encrypted_id: string }).encrypted_id).toBe(
86-
ID.projects.inference,
87-
);
88-
});
89-
9079
it("204s on project deletion", () => {
9180
const r = resolveMock(
9281
url(`/projects/${ID.projects.training}`),
Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2-
import { render, screen } from "@testing-library/react";
3-
import ShareProjectButton from "@/components/share-project-button";
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
5+
const encryptProjectIdMock = vi.hoisted(() => vi.fn());
6+
vi.mock("@/utils/crypto", () => ({
7+
encryptProjectId: encryptProjectIdMock,
8+
}));
49

5-
const originalFetch = globalThis.fetch;
10+
const copyMock = vi.hoisted(() => vi.fn(() => true));
11+
vi.mock("copy-to-clipboard", () => ({
12+
default: copyMock,
13+
}));
14+
15+
import ShareProjectButton from "@/components/share-project-button";
616

717
beforeEach(() => {
8-
globalThis.fetch = vi.fn(async () => ({
9-
ok: true,
10-
status: 200,
11-
json: async () => ({ encrypted_id: "enc-123" }),
12-
})) as unknown as typeof fetch;
18+
encryptProjectIdMock.mockReset();
19+
encryptProjectIdMock.mockResolvedValue("enc-token-xyz");
20+
copyMock.mockReset();
21+
copyMock.mockReturnValue(true);
1322
});
1423

1524
afterEach(() => {
16-
globalThis.fetch = originalFetch;
1725
vi.restoreAllMocks();
1826
});
1927

@@ -23,6 +31,7 @@ describe("ShareProjectButton", () => {
2331
<ShareProjectButton projectId="p1" isPublic={false} />,
2432
);
2533
expect(container.textContent).toBe("");
34+
expect(encryptProjectIdMock).not.toHaveBeenCalled();
2635
});
2736

2837
it("renders the share trigger for public projects", () => {
@@ -31,4 +40,27 @@ describe("ShareProjectButton", () => {
3140
screen.getByRole("button", { name: /share project/i }),
3241
).toBeInTheDocument();
3342
});
43+
44+
it("computes the encrypted id client-side when the popover opens", async () => {
45+
render(<ShareProjectButton projectId="p1" isPublic={true} />);
46+
47+
await userEvent.click(
48+
screen.getByRole("button", { name: /share project/i }),
49+
);
50+
51+
await waitFor(() =>
52+
expect(encryptProjectIdMock).toHaveBeenCalledWith("p1"),
53+
);
54+
55+
// The encrypted token shows up in the share-link input.
56+
const input = await screen.findByDisplayValue(
57+
/\/public\/projects\/enc-token-xyz$/,
58+
);
59+
expect(input).toBeInTheDocument();
60+
});
61+
62+
it("does not call the encryptor before the popover is opened", () => {
63+
render(<ShareProjectButton projectId="p1" isPublic={true} />);
64+
expect(encryptProjectIdMock).not.toHaveBeenCalled();
65+
});
3466
});

0 commit comments

Comments
 (0)