diff --git a/.gitignore b/.gitignore index 16e9cbed7..28cf5727f 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,6 @@ tests/test_data/rapl/* credentials* .codecarbon.config* scripts/agent-vm.personal.config.sh + +# Vite cache +.vite/ diff --git a/carbonserver/.env.example b/carbonserver/.env.example index ce8189276..9c0a405b2 100644 --- a/carbonserver/.env.example +++ b/carbonserver/.env.example @@ -1 +1,6 @@ DATABASE_URL= + +# Matomo analytics for server-side events (account created, first run received). +# Off unless both are set. Same instance and site as the webapp's VITE_MATOMO_*. +MATOMO_URL= +MATOMO_SITE_ID= diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_runs.py b/carbonserver/carbonserver/api/infra/repositories/repository_runs.py index c14fb25e8..0919c22ca 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_runs.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_runs.py @@ -117,6 +117,14 @@ def get_runs_from_experiment(self, experiment_id) -> List[Run]: raise EmptyResultException(f"No runs for experiment {experiment_id}") return [self.map_sql_to_schema(e) for e in res] + def count_runs_from_experiment(self, experiment_id) -> int: + with self.session_factory() as session: + return ( + session.query(func.count(SqlModelRun.id)) + .filter(SqlModelRun.experiment_id == experiment_id) + .scalar() + ) + @staticmethod def map_sql_to_schema(run: SqlModelRun) -> Run: """Convert a models.Run to a schemas.Run diff --git a/carbonserver/carbonserver/api/routers/authenticate.py b/carbonserver/carbonserver/api/routers/authenticate.py index e6abe8481..ef5ff8be7 100644 --- a/carbonserver/carbonserver/api/routers/authenticate.py +++ b/carbonserver/carbonserver/api/routers/authenticate.py @@ -19,6 +19,8 @@ LOGGER = logging.getLogger(__name__) SESSION_COOKIE_NAME = "user_session" +# Raw id_token, kept only as the hint for provider-side logout. +ID_TOKEN_COOKIE_NAME = "user_id_token" router = APIRouter() @@ -79,12 +81,23 @@ async def get_login( url = f"{base_url}home" response = auth_provider.create_redirect_response(url) + # A Secure cookie is dropped outright over plain http, which is every + # local dev session, so the requirement is relaxed there. + cookie_options = { + "httponly": True, + "secure": settings.environment not in ("local", "develop"), + "samesite": "lax", + } response.set_cookie( - SESSION_COOKIE_NAME, - token["access_token"], - httponly=True, - secure=True, + SESSION_COOKIE_NAME, token["access_token"], **cookie_options ) + # Its own cookie rather than the session: the session is a signed + # client-side cookie capped at ~4KB, and an id_token is large enough to + # push it over and get it dropped silently. + if isinstance(token.get("id_token"), str): + response.set_cookie( + ID_TOKEN_COOKIE_NAME, token["id_token"], **cookie_options + ) return response return await auth_provider.get_authorize_url(request, str(login_url)) @@ -94,13 +107,17 @@ async def get_login( async def logout( request: Request, response: Response, - auth_user: UserWithAuthDependency = Depends(UserWithAuthDependency), auth_provider: Optional[OIDCAuthProvider] = Depends( Provide[ServerContainer.auth_provider] ), ): """ - Logout user by clearing session and removing cookie + Logout user by ending the provider's SSO session, clearing our own session + and removing the cookie. + + Deliberately takes no auth dependency: the session token is verified against + the provider, so an expired or invalid one would be refused with a 401 before + this ran — which is exactly when the user most needs to be able to sign out. """ if auth_provider is None: return RedirectResponse(settings.default_redirect_url) @@ -110,10 +127,41 @@ async def logout( if access_token: await auth_provider.revoke_token(access_token) - base_url = request.base_url - response = auth_provider.create_redirect_response(str(base_url)) + id_token = request.cookies.get(ID_TOKEN_COOKIE_NAME) + + # Land the user back on the dashboard, not on the API host. The trailing + # slash is deliberate: the provider matches this against the client's + # registered post-logout URIs exactly. + post_logout_url = ( + f"{settings.frontend_url.rstrip('/')}/" + if settings.frontend_url + else str(request.base_url) + ) + redirect_url = await auth_provider.get_end_session_url( + post_logout_url, id_token=id_token + ) + + response = auth_provider.create_redirect_response(redirect_url or post_logout_url) response.delete_cookie(SESSION_COOKIE_NAME) + response.delete_cookie(ID_TOKEN_COOKIE_NAME) if hasattr(request, "session"): request.session.clear() return response + + +@router.get("/auth/account", name="account") +@inject +async def account( + auth_provider: Optional[OIDCAuthProvider] = Depends( + Provide[ServerContainer.auth_provider] + ), +): + """ + Send the user to the provider's account console, which owns their email and + password. The issuer lives in the server config only, so the frontend links + here rather than needing the URL itself. + """ + if auth_provider is None: + return RedirectResponse(settings.default_redirect_url) + return auth_provider.create_redirect_response(auth_provider.get_account_url()) diff --git a/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py b/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py index b38eb5f1b..76559902c 100644 --- a/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py +++ b/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py @@ -7,6 +7,7 @@ import logging from typing import Any, Dict, Optional +from urllib.parse import urlencode from authlib.integrations.starlette_client import OAuth from fastapi import Response @@ -96,6 +97,50 @@ async def revoke_token(self, token: str) -> None: except Exception as e: LOGGER.warning("Token revocation failed (non-blocking): %s", e) + async def get_end_session_url( + self, post_logout_redirect_uri: str, id_token: Optional[str] = None + ) -> Optional[str]: + """Build the provider's RP-initiated logout URL (OIDC session management). + + Revoking the access token only invalidates our own credential — the + provider keeps its SSO session, so the next login silently re-issues a + token and the user never appears to have signed out. Sending the browser + here is what actually ends that session. + + Returns None when the provider exposes no end_session_endpoint, so the + caller can fall back to a plain local logout. + """ + try: + metadata = await self.client.load_server_metadata() + except Exception as e: # never block logout on a metadata failure + LOGGER.warning("Could not load OIDC metadata for logout: %s", e) + return None + + end_session_endpoint = metadata.get("end_session_endpoint") + if not end_session_endpoint: + LOGGER.debug( + "OIDC provider does not expose an end_session_endpoint, " + "falling back to a local-only logout" + ) + return None + + params = {"post_logout_redirect_uri": post_logout_redirect_uri} + # A redirect target is only honoured alongside one of these two; the + # id_token is the stronger hint, but it is absent if the session was + # established before we started keeping it. + if id_token: + params["id_token_hint"] = id_token + else: + params["client_id"] = settings.oidc_client_id + return f"{end_session_endpoint}?{urlencode(params)}" + + @staticmethod + def get_account_url() -> str: + """The provider-hosted account console, where users manage their own + email and password. Derived from the issuer the same way the login URL + is, so there is nothing extra to configure.""" + return f"{settings.oidc_issuer_url.rstrip('/')}/account/" + @staticmethod def create_redirect_response(url: str) -> Response: """RedirectResponse doesn't work with clevercloud, so we return a HTML page with a script to redirect the user diff --git a/carbonserver/carbonserver/api/services/matomo_tracker.py b/carbonserver/carbonserver/api/services/matomo_tracker.py new file mode 100644 index 000000000..ffbdd5000 --- /dev/null +++ b/carbonserver/carbonserver/api/services/matomo_tracker.py @@ -0,0 +1,51 @@ +import logging +import secrets +import threading +from typing import Optional + +import httpx + +LOGGER = logging.getLogger(__name__) + + +class MatomoTracker: + """Send events to Matomo's HTTP Tracking API for things the browser cannot + see, such as the account being created on the API side. + + Events carry a category, an action and an optional name, and nothing that + identifies a person: the visitor id is random per event, so they only + count occurrences. Sending is best effort and never blocks or fails the + request that triggered it. Without a URL and site id it does nothing. + """ + + def __init__(self, url: str = "", site_id: str = "") -> None: + self._endpoint = f"{url.rstrip('/')}/matomo.php" if url else "" + self._site_id = site_id + + @property + def enabled(self) -> bool: + return bool(self._endpoint and self._site_id) + + def track_event( + self, category: str, action: str, name: Optional[str] = None + ) -> None: + if not self.enabled: + return + params = { + "idsite": self._site_id, + "rec": "1", + "apiv": "1", + "e_c": category, + "e_a": action, + "_id": secrets.token_hex(8), + "rand": secrets.token_hex(4), + } + if name: + params["e_n"] = name + threading.Thread(target=self._send, args=(params,), daemon=True).start() + + def _send(self, params: dict) -> None: + try: + httpx.get(self._endpoint, params=params, timeout=3.0) + except httpx.HTTPError as error: + LOGGER.debug("Matomo event not sent: %s", error) diff --git a/carbonserver/carbonserver/api/services/run_service.py b/carbonserver/carbonserver/api/services/run_service.py index 801166204..45c87afd1 100644 --- a/carbonserver/carbonserver/api/services/run_service.py +++ b/carbonserver/carbonserver/api/services/run_service.py @@ -5,6 +5,7 @@ from carbonserver.api.infra.repositories.repository_runs import SqlAlchemyRepository from carbonserver.api.schemas import Run, RunCreate, User from carbonserver.api.services.auth_context import AuthContext +from carbonserver.api.services.matomo_tracker import MatomoTracker def _not_allowed() -> UserException: @@ -21,12 +22,22 @@ def __init__( self, run_repository: SqlAlchemyRepository, auth_context: AuthContext, + matomo_tracker: MatomoTracker | None = None, ): self._repository = run_repository self._auth_context = auth_context + self._matomo_tracker = matomo_tracker def add_run(self, run: RunCreate, user: User = None) -> Run: created_run = self._repository.add_run(run) + # The last step of the activation funnel: an experiment receiving its + # first run shows someone actually integrated the package. + if ( + self._matomo_tracker + and self._matomo_tracker.enabled + and self._repository.count_runs_from_experiment(run.experiment_id) == 1 + ): + self._matomo_tracker.track_event("Activation", "first_run_received") return created_run def read_run(self, run_id: UUID, user: User = None) -> Run: diff --git a/carbonserver/carbonserver/api/services/signup_service.py b/carbonserver/carbonserver/api/services/signup_service.py index f6f42d12c..0e3a2c79e 100644 --- a/carbonserver/carbonserver/api/services/signup_service.py +++ b/carbonserver/carbonserver/api/services/signup_service.py @@ -13,6 +13,7 @@ from carbonserver.api.infra.repositories.repository_users import ( SqlAlchemyRepository as UserRepository, ) +from carbonserver.api.services.matomo_tracker import MatomoTracker from carbonserver.api.schemas import ( OrganizationCreate, ProjectCreate, @@ -29,10 +30,12 @@ def __init__( user_repository: UserRepository, organization_repository: OrganizationRepository, project_repository: ProjectRepository, + matomo_tracker: MatomoTracker | None = None, ) -> None: self._user_repository: UserRepository = user_repository self._organization_repository: OrganizationRepository = organization_repository self._project_repository: ProjectRepository = project_repository + self._matomo_tracker = matomo_tracker def sign_up( self, @@ -41,6 +44,8 @@ def sign_up( created_user = self._user_repository.create_user(user) subscribed_user = self.new_user_setup(created_user) LOGGER.info(f"User {subscribed_user.id} created") + if self._matomo_tracker: + self._matomo_tracker.track_event("Activation", "account_created") return subscribed_user def subscribe_user_to_org( diff --git a/carbonserver/carbonserver/config.py b/carbonserver/carbonserver/config.py index 30c6c6c21..53312037f 100644 --- a/carbonserver/carbonserver/config.py +++ b/carbonserver/carbonserver/config.py @@ -40,6 +40,11 @@ class Settings(BaseSettings): "", validation_alias=AliasChoices("DEFAULT_REDIRECT_URL", "default_redirect_url"), ) + # Matomo analytics for server-side events. Off unless both are set. + matomo_url: str = Field("", validation_alias=AliasChoices("MATOMO_URL", "matomo_url")) + matomo_site_id: str = Field( + "", validation_alias=AliasChoices("MATOMO_SITE_ID", "matomo_site_id") + ) environment: str = Field("production") jwt_key: str = Field("", validation_alias=AliasChoices("JWT_KEY", "jwt_key")) api_port: int = Field(8080, validation_alias=AliasChoices("API_PORT", "api_port")) diff --git a/carbonserver/carbonserver/container.py b/carbonserver/carbonserver/container.py index cd6fab150..7e2967850 100644 --- a/carbonserver/carbonserver/container.py +++ b/carbonserver/carbonserver/container.py @@ -17,6 +17,7 @@ ) from carbonserver.api.services.emissions_service import EmissionService from carbonserver.api.services.experiments_service import ExperimentService +from carbonserver.api.services.matomo_tracker import MatomoTracker from carbonserver.api.services.organization_service import OrganizationService from carbonserver.api.services.project_service import ProjectService from carbonserver.api.services.project_token_service import ProjectTokenService @@ -174,10 +175,17 @@ class ServerContainer(containers.DeclarativeContainer): auth_context=auth_context, ) + matomo_tracker = providers.Singleton( + MatomoTracker, + url=settings.matomo_url, + site_id=settings.matomo_site_id, + ) + run_service = providers.Factory( RunService, run_repository=run_repository, auth_context=auth_context, + matomo_tracker=matomo_tracker, ) sign_up_service = providers.Factory( @@ -185,4 +193,5 @@ class ServerContainer(containers.DeclarativeContainer): user_repository=user_repository, organization_repository=organization_repository, project_repository=project_repository, + matomo_tracker=matomo_tracker, ) diff --git a/carbonserver/tests/api/routers/test_authenticate.py b/carbonserver/tests/api/routers/test_authenticate.py index 61a3533dc..8b28425e1 100644 --- a/carbonserver/tests/api/routers/test_authenticate.py +++ b/carbonserver/tests/api/routers/test_authenticate.py @@ -176,3 +176,95 @@ async def test_revoke_token_exception(oidc_provider, mock_oidc_client): # Should not raise await oidc_provider.revoke_token("test-access-token") + + +# --- RP-initiated logout tests --- + + +@pytest.mark.asyncio +async def test_end_session_url_prefers_id_token_hint(oidc_provider, mock_oidc_client): + """The id_token is the hint the provider trusts, so it wins when present.""" + mock_oidc_client.load_server_metadata.return_value = { + "end_session_endpoint": "https://auth.example.com/logout", + } + + url = await oidc_provider.get_end_session_url( + "https://dashboard.test", id_token="raw-id-token" + ) + + assert url.startswith("https://auth.example.com/logout?") + assert "id_token_hint=raw-id-token" in url + assert "post_logout_redirect_uri=https%3A%2F%2Fdashboard.test" in url + assert "client_id" not in url + + +@pytest.mark.asyncio +async def test_end_session_url_falls_back_to_client_id(oidc_provider, mock_oidc_client): + """Sessions opened before we kept the id_token still need a redirect target, + which the provider only honours alongside a client_id.""" + mock_oidc_client.load_server_metadata.return_value = { + "end_session_endpoint": "https://auth.example.com/logout", + } + + url = await oidc_provider.get_end_session_url("https://dashboard.test") + + assert "client_id=" in url + assert "id_token_hint" not in url + + +@pytest.mark.asyncio +async def test_end_session_url_without_endpoint(oidc_provider, mock_oidc_client): + """No end_session_endpoint means a local-only logout, not a failure.""" + mock_oidc_client.load_server_metadata.return_value = {} + + assert await oidc_provider.get_end_session_url("https://dashboard.test") is None + + +@pytest.mark.asyncio +async def test_end_session_url_metadata_failure(oidc_provider, mock_oidc_client): + """Logout must never break because the provider is unreachable.""" + mock_oidc_client.load_server_metadata.side_effect = ConnectionError("unreachable") + + assert await oidc_provider.get_end_session_url("https://dashboard.test") is None + + +def test_account_url_derives_from_issuer(monkeypatch): + """The account console is derived from the issuer, so there is no separate + setting to keep in sync.""" + monkeypatch.setattr( + "carbonserver.api.services.auth_providers.oidc_auth_provider.settings." + "oidc_issuer_url", + "https://authentication.codecarbon.io/realms/codecarbon/", + raising=False, + ) + + assert ( + OIDCAuthProvider.get_account_url() + == "https://authentication.codecarbon.io/realms/codecarbon/account/" + ) + + +@pytest.mark.asyncio +async def test_login_keeps_raw_id_token_in_its_own_cookie(monkeypatch): + """The logout hint must live outside the size-capped session cookie, and + only a raw JWT string is usable as a hint.""" + monkeypatch.setattr(authenticate.settings, "frontend_url", "https://dashboard.test") + token = {"access_token": "access-secret", "id_token": "raw.id.token"} + request = MagicMock() + request.url_for.return_value = "https://api.test/auth/login" + request.base_url = "https://api.test/" + request.session = {} + auth_provider = MagicMock() + auth_provider.client.authorize_access_token = AsyncMock(return_value=token) + auth_provider.create_redirect_response.side_effect = lambda url: Response(url) + + response = await authenticate.get_login( + request=request, + code="authorization-code", + sign_up_service=MagicMock(), + auth_provider=auth_provider, + ) + + cookies = response.headers.getlist("set-cookie") + assert any(c.startswith("user_id_token=raw.id.token;") for c in cookies) + assert "id_token" not in request.session diff --git a/carbonserver/tests/api/service/test_matomo_tracker.py b/carbonserver/tests/api/service/test_matomo_tracker.py new file mode 100644 index 000000000..0289a7a74 --- /dev/null +++ b/carbonserver/tests/api/service/test_matomo_tracker.py @@ -0,0 +1,25 @@ +from unittest import mock + +from carbonserver.api.services.matomo_tracker import MatomoTracker + + +def test_tracker_is_disabled_without_url_and_site_id(): + assert not MatomoTracker().enabled + assert not MatomoTracker(url="https://matomo.example.com").enabled + assert not MatomoTracker(site_id="3").enabled + + +def test_tracker_sends_nothing_when_disabled(): + with mock.patch("carbonserver.api.services.matomo_tracker.httpx.get") as get: + MatomoTracker().track_event("Activation", "account_created") + get.assert_not_called() + + +def test_tracker_sends_an_anonymous_event(): + tracker = MatomoTracker(url="https://matomo.example.com/", site_id="3") + with mock.patch("carbonserver.api.services.matomo_tracker.threading.Thread") as t: + tracker.track_event("Activation", "account_created") + params = t.call_args.kwargs["args"][0] + assert params["idsite"] == "3" + assert (params["e_c"], params["e_a"]) == ("Activation", "account_created") + assert set(params) <= {"idsite", "rec", "apiv", "e_c", "e_a", "e_n", "_id", "rand"} diff --git a/carbonserver/tests/api/service/test_organization_service.py b/carbonserver/tests/api/service/test_organization_service.py index a7e1c3ccc..cc183843d 100644 --- a/carbonserver/tests/api/service/test_organization_service.py +++ b/carbonserver/tests/api/service/test_organization_service.py @@ -121,6 +121,42 @@ def test_organization_service_add_user_by_mail_adds_non_admin_member(): ) +def test_organization_service_remove_user_requires_admin(): + repository_mock: OrganizationRepository = mock.Mock(spec=OrganizationRepository) + user_repository_mock: UserRepository = mock.Mock(spec=UserRepository) + auth_context = mock.Mock() + auth_context.can_write_organization.return_value = False + organization_service: OrganizationService = OrganizationService( + user_repository=user_repository_mock, + organization_repository=repository_mock, + auth_context=auth_context, + ) + + with pytest.raises(UserException) as exc_info: + organization_service.remove_user( + organization_id=ORG_ID, user_id=USER_ID_1, user=DUMMY_USER + ) + + assert exc_info.value.error.code == NotAllowedErrorEnum.NOT_IN_ORGANISATION + user_repository_mock.unsubscribe_user_from_org.assert_not_called() + + +def test_organization_service_remove_user_removes_an_admin_when_another_remains(): + repository_mock: OrganizationRepository = mock.Mock(spec=OrganizationRepository) + user_repository_mock: UserRepository = mock.Mock(spec=UserRepository) + organization_service = _organization_service(repository_mock, user_repository_mock) + other_admin = ORG_MEMBER.model_copy(update={"is_admin": True}) + repository_mock.list_users.return_value = [ORG_USER, other_admin] + + organization_service.remove_user( + organization_id=ORG_ID, user_id=other_admin.id, user=DUMMY_USER + ) + + user_repository_mock.unsubscribe_user_from_org.assert_called_once_with( + user_id=other_admin.id, organization_id=ORG_ID + ) + + def test_organiation_service_retrieves_all_existing_organizations(): expected_org_ids_list = [ORG_ID, ORG_ID_2] repository_mock: OrganizationRepository = mock.Mock(spec=OrganizationRepository) diff --git a/carbonserver/tests/api/service/test_run_service.py b/carbonserver/tests/api/service/test_run_service.py index 09b900c55..9e9580525 100644 --- a/carbonserver/tests/api/service/test_run_service.py +++ b/carbonserver/tests/api/service/test_run_service.py @@ -149,3 +149,28 @@ def test_run_service_retrieves_correct_last_run_for_project_id(): actual_run = run_service.read_project_last_run(PROJECT_ID, START_DATE, END_DATE) assert actual_run.id == expected_run_id + + +def test_run_service_tracks_only_the_first_run_of_an_experiment(): + repository_mock: SqlAlchemyRepository = mock.Mock(spec=SqlAlchemyRepository) + tracker_mock = mock.Mock(enabled=True) + run_service = RunService( + repository_mock, auth_context=FakeAuthContext(), matomo_tracker=tracker_mock + ) + repository_mock.add_run.return_value = RUN_1 + run_to_create = RunCreate( + id=RUN_ID, + timestamp="2021-04-04T08:43:00+02:00", + experiment_id=EXPERIMENT_ID, + ) + + repository_mock.count_runs_from_experiment.return_value = 1 + run_service.add_run(run_to_create) + tracker_mock.track_event.assert_called_once_with( + "Activation", "first_run_received" + ) + + tracker_mock.reset_mock() + repository_mock.count_runs_from_experiment.return_value = 2 + run_service.add_run(run_to_create) + tracker_mock.track_event.assert_not_called() diff --git a/docs/how-to/agent-instructions.md b/docs/how-to/agent-instructions.md index 54aaeabb5..0d9454e87 100644 --- a/docs/how-to/agent-instructions.md +++ b/docs/how-to/agent-instructions.md @@ -34,6 +34,8 @@ Here's what you need to know to navigate and contribute effectively. - Modern React web application - Connects to the API backend - Run with: `cd webapp && pnpm dev` + - **Before editing anything under `webapp/`, read `webapp/AGENTS.md` first** — + it is binding for all webapp work (propose before editing, never commit). ### Key Directories diff --git a/privacy_policy_en.md b/privacy_policy_en.md index c5bcab6dc..7767260f9 100644 --- a/privacy_policy_en.md +++ b/privacy_policy_en.md @@ -153,6 +153,10 @@ We use both Session and Persistent Cookies for the purposes set out below: +#### Usage Analytics + +The web dashboard measures usage with a self-hosted Matomo instance: pages visited and a few actions (for example creating a project or applying a date range). Matomo runs in cookieless mode there, so it stores nothing on Your device, and these events carry no account identifier, email or IP-derived profile. Some events, such as account creation, are recorded by the API and are equally anonymous. + For more information about the cookies we use and your choices regarding cookies, please visit our Cookies Policy or the Cookies section of our Privacy Policy. Cookies are not used for API/SDK authentication. diff --git a/webapp/.env.development b/webapp/.env.development index 6c316dc40..3fc96656a 100644 --- a/webapp/.env.development +++ b/webapp/.env.development @@ -10,6 +10,13 @@ VITE_BASE_URL=http://localhost:3000 VITE_USE_MOCK_DATA=true +# Matomo analytics. Tracking is off unless both are set, so leave them empty +# locally. Production uses the webapp site (ID 3) of the shared instance. +# The API reads MATOMO_URL / MATOMO_SITE_ID (same values) for server-side +# events such as account creation. +VITE_MATOMO_URL=https://tgudxkgxiqpdslkkqaz5-matomo.services.clever-cloud.com/ +VITE_MATOMO_SITE_ID=3 + # 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 index a58959bd0..407e40b13 100644 --- a/webapp/.env.example +++ b/webapp/.env.example @@ -14,11 +14,14 @@ VITE_BASE_URL=http://localhost:3000 # is required. VITE_USE_MOCK_DATA=false +# Matomo analytics. Tracking is off unless both are set, so leave them empty +# locally. Production uses the webapp site (ID 3) of the shared instance. +# The API reads MATOMO_URL / MATOMO_SITE_ID (same values) for server-side +# events such as account creation. +VITE_MATOMO_URL= +VITE_MATOMO_SITE_ID= + # 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/AGENTS.md b/webapp/AGENTS.md new file mode 100644 index 000000000..dad4964ae --- /dev/null +++ b/webapp/AGENTS.md @@ -0,0 +1,198 @@ +# Webapp agent instructions + +Scope: everything under `webapp/`. The repository root has its own `AGENTS.md` +covering the Python package and the API — this file adds to it, and wins where +the two disagree about webapp code. + +## Working agreement + +1. **Propose before editing.** Read the relevant code first, then say what will + change and where, and stop for approval. Do not edit on the same turn as the + proposal — the review gate is the point, so an unrequested implementation is + the failure mode even when the change looks obvious. +2. **Follow the tests.** Check `tests/` for coverage of the feature being + touched and update it in the same change. If nothing covers it but a similar + component or page is tested, ask before adding new tests rather than + inventing a convention. +3. **Sweep the docs.** After a change, check the `*.md` files for anything it + invalidates and update them briefly — what is necessary, not a rewrite. +4. **Hand over the commit; never make it.** See [Commits](#commits) — this one + has no exceptions. + +## Commits + +**Never commit. Never stage.** Not with `git commit`, not with `git add`, not as +a convenience at the end of a task — leave both to the user, every time. + +Finish instead by giving the commit message as text: medium length, neither a +one-liner nor a changelog. Only what matters, not verbose — what changed, and +why it matters. + +Then ask whether to fold anything already staged into the same message, rather +than assuming either way. + +## Stack + +React 19 + Vite + TypeScript, Tailwind v4, Radix primitives, SWR for fetching, +Zod for response schemas, `sonner` for toasts, `react-router-dom` v7. `pnpm` is +the package manager, Node 24 in CI. `@/` resolves to `src/`. + +## Layout + +- `src/api/` — one module per resource, plus `client.ts` (fetch wrapper), + `schemas.ts` (Zod types), `swr.ts` (hooks), `errors.ts`. +- `src/components/` — kebab-case files. `src/components/ui/` holds the shared + primitives, `src/components/icons/` the SVG icons. +- `src/pages/` — PascalCase, one per route; `src/layouts/`, `src/hooks/`, + `src/helpers/`, `src/utils/` as named. +- `tests/` — Vitest + Testing Library, mirroring `src/`. `e2e/` — Playwright. + +## Component conventions + +There are two generations of UI primitives in `src/components/ui/`. The +redesign ones — `form-field.tsx`, `modal-header.tsx`, `primary-button.tsx`, +`secondary-button.tsx`, `icon-button.tsx`, `menu.tsx`, `tab-nav.tsx`, +`breadcrumb.tsx` — carry the current design. The older shadcn ones — +`input.tsx`, `label.tsx`, `button.tsx`, `card.tsx` — remain only because +pre-redesign screens still use them. **New work uses the redesign layer.** + +Modals share one shell; copy it rather than restating it: + +```tsx + +``` + +`create-project-modal.tsx`, `create-experiment-modal.tsx` and +`create-organization-modal.tsx` are the reference implementations. Submission +goes through `toast.promise`; the submit button is disabled while pending and +while the required field is blank, instead of validating on click. + +## Implementing a Figma design + +The task supplies the frame link, and sometimes a note that the screen closely +resembles an existing one — verify that claim against the design before leaning +on it. Implement every state the design provides in one pass. + +### The four things that matter most + +1. **Responsive, not pixel-perfect.** Frames are 1440x1024 desktop only; there + is no mobile design. Don't reproduce absolute coordinates or fixed sizes — + one container gutter for horizontal placement, gaps for vertical rhythm, + working down to ~320px. Make the mobile calls yourself, coherently. + +2. **Ask when the design shows what the app doesn't have.** This is a UI + redesign, not an architecture redesign. Data with no API field, menus whose + contents are undrawn, controls with no defined action — stop and ask rather + than inventing endpoints or features. Batch every question into one round + before coding, recommendation first. + + Decide these yourself instead of asking: contradictory measurements for one + edge (pick one, comment why), sizes that differ between two frames of the + same component, and elements that are plainly a Figma component default + rather than intent. Report what you decided afterwards. + +3. **Extend the redesign; don't start a parallel style.** First find out where + the redesign stands: read whatever screens and shared primitives already + follow it. If nothing does, this is the first page and you are setting the + vocabulary — build the recurring treatments as shared components from the + start rather than inline, so the next page has something to inherit. If work + already exists, take the page shell from it, reuse every primitive that fits, + and extend one with a prop rather than forking it; add new shared components + only where the design genuinely introduces something the existing set has no + answer for. Either way, shared chrome — navigation, account menu — stays in + its own components: never rebuild an in-page nav, even where an older frame + draws one. + +4. **Components, not class-string constants.** Never `const FOO = "flex +items-center …"` as a styling layer, and no helpers module of class strings. + A repeated treatment is a component in `ui/`. Local constants are fine only + when two elements inside one component share them. + +### House style for design work + +- Tokens only: the design system's colour and type tokens, and the spacing and + radius scales in the Tailwind config. No hardcoded hex or font sizes. +- kebab-case filenames, PascalCase components; pages stay PascalCase. +- Comments are sparse and concise, and explain reasoning only: why a value + differs from the design, how an unspecified case was resolved. Never narrate + the code or explain routine implementation. No long blocks, no Figma node + ids. Wrap at 80 columns. +- Respect `prefers-reduced-motion`; tie labels to inputs, keep focus-visible + rings, set `aria-current` on the current item, give icon-only buttons + `sr-only` text. +- Verify with `tsc`, eslint, prettier, vitest and `vite build`, and update any + test whose copy changed. You cannot see the app running — it needs the API — + so state what is unverified rather than implying you checked it. + +### Known traps — check only the ones the page actually hits + +Ignore the rest; none of these are things to go hunting for. + +- **A `type-*` class not taking effect.** If a primitive from the underlying + component library sets its own `text-*`, twMerge keeps both and the utility + wins (a 24px title renders at 18px). There, and only there, name the size as + a utility; everywhere else `type-*` remains the convention. +- **Building a dialog:** ours are flex-centred and fade + rise in place + (`cc-dialog-*`). Don't reintroduce transform centring or `slide-in-from-*`. +- **Adding a dropdown to a padded trigger:** Radix anchors to the trigger's + box, not its glyph — cancel the padding with `sideOffset`/`alignOffset`. +- **Gating an SWR key on transient UI state** (a menu's `open`, say): the data + vanishes when it flips and the UI visibly regroups. Latch the condition. +- **A mutation that refetches while a form is open:** key reset effects on ids + or fields so the refresh can't clobber in-progress edits. +- **Rows in a list:** the row text lights as one item on hover, with the + actions cell excluded so its small trigger reports its own hitbox, and + padding on the link so the whole band is clickable. Follow whatever row the + redesign already has. +- **Forms:** a real `