Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
17 changes: 10 additions & 7 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias>`).
- 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
6 changes: 6 additions & 0 deletions webapp/e2e/landing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion webapp/src/api/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@ export async function getExperiments(projectId: string): Promise<Experiment[]> {
(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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this catch, there is already one at line 33

return [];
}
}
Expand Down
10 changes: 0 additions & 10 deletions webapp/src/api/mock/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},

Expand Down
3 changes: 1 addition & 2 deletions webapp/src/api/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ export async function getOrganizationEmissionsByProject(
export async function getOrganizations(): Promise<Organization[]> {
try {
return await fetchApi("/organizations", OrganizationSchema.array());
} catch (error) {
Comment thread
SaboniAmine marked this conversation as resolved.
console.error("[getOrganizations] failed", error);

return [];
}
}
Expand Down
7 changes: 6 additions & 1 deletion webapp/src/components/createOrganizationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const CreateOrganizationModal: React.FC<ModalProps> = ({
<Label htmlFor="org-name">Organization Name</Label>
<Input
id="org-name"
type="text"
id="org-name"type="text"
Comment thread
SaboniAmine marked this conversation as resolved.
Outdated
value={formData.name}
onChange={(e) =>
setFormData({
Expand All @@ -102,6 +102,11 @@ const CreateOrganizationModal: React.FC<ModalProps> = ({
placeholder="Organization Name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="org-description">
Organization Description
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="org-description">
Organization Description
Expand Down
7 changes: 6 additions & 1 deletion webapp/src/components/createProjectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const CreateProjectModal: React.FC<ModalProps> = ({
<Label htmlFor="project-name">Project Name</Label>
<Input
id="project-name"
type="text"
id="project-name"type="text"
Comment thread
SaboniAmine marked this conversation as resolved.
Outdated
value={formData.name}
onChange={(e) =>
setFormData({
Expand All @@ -98,6 +98,11 @@ const CreateProjectModal: React.FC<ModalProps> = ({
placeholder="Project Name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-description">
Project Description
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="project-description">
Project Description
Expand Down
38 changes: 18 additions & 20 deletions webapp/src/components/share-project-button.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { fetchApi } from "@/api/client";
import { z } from "zod";
import { encryptProjectId } from "@/utils/crypto";
import copy from "copy-to-clipboard";
import { CheckIcon, CopyIcon, LockIcon, Share2Icon } from "lucide-react";
import { CheckIcon, CopyIcon, Share2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";

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

useEffect(() => {
const fetchEncryptedId = async () => {
if (isPublic && projectId && isOpen && !encryptedId) {
try {
setIsLoading(true);
const result = await fetchApi(
`/projects/${projectId}/share-link`,
z.object({ encrypted_id: z.string() }),
);
const encrypted = result.encrypted_id;
setEncryptedId(encrypted);
} catch (error) {
console.error("Failed to encrypt project ID:", error);
let cancelled = false;
const computeEncryptedId = async () => {
if (!(isPublic && projectId && isOpen && !encryptedId)) return;
try {
setIsLoading(true);
const encrypted = await encryptProjectId(projectId);
if (!cancelled) setEncryptedId(encrypted);
} catch (error) {
console.error("Failed to encrypt project ID:", error);
if (!cancelled) {
toast.error("Failed to generate secure sharing link");
} finally {
setIsLoading(false);
}
} finally {
if (!cancelled) setIsLoading(false);
}
};

fetchEncryptedId();
computeEncryptedId();
return () => {
cancelled = true;
};
}, [projectId, isPublic, isOpen, encryptedId]);

const publicUrl = encryptedId
Expand Down
3 changes: 3 additions & 0 deletions webapp/src/pages/OrgDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ export default function OrgDashboardPage() {
<p className="text-sm font-medium">
Of watching TV
</p>
<p className="text-sm font-medium">
Of watching TV
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
Expand Down
6 changes: 5 additions & 1 deletion webapp/src/pages/ProjectsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ export default function ProjectsPage() {
<TableBody>
{projectList &&
projectList
.sort((a, b) =>
.sort((a, b) =>a.name
.toLowerCase()
.localeCompare(
b.name.toLowerCase(),
),
a.name
.toLowerCase()
.localeCompare(
Expand Down
48 changes: 38 additions & 10 deletions webapp/src/pages/PublicProjectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions webapp/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 1 addition & 27 deletions webapp/tests/api/mock/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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}`),
Expand Down
Loading
Loading