Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
39e6e52
feat(webapp): redesign navigation, global panel and settings
Aug 22, 2026
57fdcba
feat(webapp): redesign projects page and project dialogs
Aug 22, 2026
67f6473
refactor(webapp): split the icon barrel into one module per icon
Aug 22, 2026
a5c4783
feat(webapp): restyle the switch for the redesign
Aug 22, 2026
3758108
chore(webapp): remove modules the redesign left unreachable
Aug 24, 2026
a994ce8
feat(webapp): redesign the project details page
Aug 24, 2026
e18cae0
feat(webapp): redesign the members page
Aug 24, 2026
231dae5
feat(webapp): redesign the share-project popover
Aug 24, 2026
9299a6e
Merge branch 'master' into feat/webapp-dashboard-redesign
inimaz Aug 29, 2026
9a22bdf
refactor(webapp): align create-organization modal with shared dialog …
Sep 11, 2026
18266b9
fix: Repair the create-organization modal test, which still pointed a…
Sep 11, 2026
1a84074
docs(webapp): add agent instructions for the webapp
Sep 11, 2026
8db20b2
fix(webapp): save project visibility with the settings form
Sep 11, 2026
a6d9fbf
refactor(webapp): extract the breadcrumb into a shared primitive
Sep 11, 2026
1bcce6b
docs(webapp): give the commit rule its own section
Sep 11, 2026
e7667b3
fix(webapp): use the palette gray for unselected experiment bars
Sep 11, 2026
56a6fb6
docs(webapp): make the webapp instructions load, not just point
Sep 11, 2026
a769f2a
feat(members): let admins remove members, drop the dead settings action
Sep 30, 2026
c3045b3
fix(auth): end the Keycloak session on sign-out and drop the settings…
Sep 30, 2026
4f053c5
feat(webapp): move organizations into their own sidebar button
Sep 30, 2026
8b7cbcf
feat(webapp): track page views in Matomo
Sep 30, 2026
53e62ea
feat: track webapp usage and activation events in Matomo
Sep 30, 2026
0ba0fcf
fix(webapp): give the organizations rail button its real accessible name
Sep 30, 2026
36e5b36
Merge branch 'master' into feat/webapp-dashboard-redesign
Oct 7, 2026
c0e6e37
test(members): align removal tests with master's last-admin rule
Oct 7, 2026
7615bcc
fix(auth): let logout run without a valid session
Oct 7, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,6 @@ tests/test_data/rapl/*
credentials*
.codecarbon.config*
scripts/agent-vm.personal.config.sh

# Vite cache
.vite/
5 changes: 5 additions & 0 deletions carbonserver/.env.example
Original file line number Diff line number Diff line change
@@ -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=
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 56 additions & 8 deletions carbonserver/carbonserver/api/routers/authenticate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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))

Expand All @@ -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)
Expand All @@ -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())
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions carbonserver/carbonserver/api/services/matomo_tracker.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions carbonserver/carbonserver/api/services/run_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions carbonserver/carbonserver/api/services/signup_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions carbonserver/carbonserver/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
9 changes: 9 additions & 0 deletions carbonserver/carbonserver/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -174,15 +175,23 @@ 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(
SignUpService,
user_repository=user_repository,
organization_repository=organization_repository,
project_repository=project_repository,
matomo_tracker=matomo_tracker,
)
Loading
Loading