diff --git a/docs/env.md b/docs/env.md index 2c60d3a0..f9f4743e 100644 --- a/docs/env.md +++ b/docs/env.md @@ -255,9 +255,9 @@ _Those settings are deprecated and will be removed in the future._ | Variable | Default | Description | Required | |----------|---------|-------------|----------| -| `SENTRY_DSN` | None | Sentry DSN for error tracking | Optional | -| `NEXT_PUBLIC_SENTRY_DSN` | None | Sentry DSN for error tracking | Optional | -| `NEXT_PUBLIC_SENTRY_ENVIRONMENT` | None | Sentry environment for error tracking | Optional ('production', 'development', 'staging') | +| `SENTRY_DSN` | None | Sentry DSN for error tracking, shared with the frontend through the `/config` endpoint | Optional | +| `NEXT_PUBLIC_SENTRY_DSN` | None | **Deprecated** (build-time fallback, will be removed) — use `SENTRY_DSN` | Optional | +| `NEXT_PUBLIC_SENTRY_ENVIRONMENT` | None | **Deprecated** (build-time fallback, will be removed) — the frontend now uses the backend `ENVIRONMENT` | Optional | ### Selfcheck @@ -296,18 +296,30 @@ End-to-end mail delivery probe — see [selfcheck.md](selfcheck.md) for details. ## Frontend Configuration +The frontend is configured at runtime through the backend `/api/v1.0/config/` endpoint (see the backend [Frontend settings](#frontend) section). The only build-time variable left is the API origin, needed to reach that endpoint. + | Variable | Default | Description | Required | |----------|---------|-------------|----------| | `NEXT_PUBLIC_API_ORIGIN` | `http://localhost:8901` | Frontend API origin | Dev | -| `NEXT_PUBLIC_LANGUAGES` | `[["en-US","English"],["fr-FR","Français"],["nl-NL","Nederlands"]]` | Languages available for frontend | Optional | -| `NEXT_PUBLIC_DEFAULT_LANGUAGE` | `en-US` | Default language for frontend | Optional | -| `NEXT_PUBLIC_FORCED_DEFAULT_LANGUAGE` | `false` | When `true`, the default language fallback is `NEXT_PUBLIC_DEFAULT_LANGUAGE` instead of the browser language. | Optional | -| `NEXT_PUBLIC_THEME_CONFIG` | `{theme: "white-label"}` | Theme configuration for frontend | Optional | -| `NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL` || Feedback widget API URL | Optional | -| `NEXT_PUBLIC_FEEDBACK_WIDGET_PATH` || Feedback widget path | Optional | -| `NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL` || Feedback widget channel used by the in-app survey button (authenticated header) | Optional | -| `NEXT_PUBLIC_FEEDBACK_WIDGET_HOME_CHANNEL` || Feedback widget channel used on the unauthenticated home page. Falls back to `NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL` when unset | Optional | -| `NEXT_PUBLIC_HELP_CENTER_URL` || Help center URL | Optional | + +The following build-time variables are **deprecated**: they only act as fallbacks when the backend does not provide the corresponding setting, and will be removed in a future release. + +| Deprecated variable | Replaced by (backend setting) | +|----------|---------| +| `NEXT_PUBLIC_LANGUAGES` | `LANGUAGES` | +| `NEXT_PUBLIC_DEFAULT_LANGUAGE` | `LANGUAGE_CODE` | +| `NEXT_PUBLIC_FORCED_DEFAULT_LANGUAGE` | `FRONTEND_FORCED_DEFAULT_LANGUAGE` | +| `NEXT_PUBLIC_THEME_CONFIG` | `FRONTEND_THEME_CONFIG` | +| `NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE` | `FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB` | +| `NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL` | `FRONTEND_FEEDBACK_WIDGET_CONFIG` (`api_url` key) | +| `NEXT_PUBLIC_FEEDBACK_WIDGET_PATH` | `FRONTEND_FEEDBACK_WIDGET_CONFIG` (`path` key) | +| `NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL` | `FRONTEND_FEEDBACK_WIDGET_CONFIG` (`channel` key) | +| `NEXT_PUBLIC_FEEDBACK_WIDGET_HOME_CHANNEL` | `FRONTEND_FEEDBACK_WIDGET_CONFIG` (`home_channel` key) | +| `NEXT_PUBLIC_HELP_CENTER_URL` | `FRONTEND_HELP_CENTER_URL` | +| `NEXT_PUBLIC_LAGAUFRE_WIDGET_API_URL` | `FRONTEND_LAGAUFRE_WIDGET_CONFIG` (`api_url` key) | +| `NEXT_PUBLIC_LAGAUFRE_WIDGET_PATH` | `FRONTEND_LAGAUFRE_WIDGET_CONFIG` (`path` key) | +| `NEXT_PUBLIC_SENTRY_DSN` | `SENTRY_DSN` | +| `NEXT_PUBLIC_SENTRY_ENVIRONMENT` | `ENVIRONMENT` (backend environment) | ## Development Tools @@ -410,10 +422,19 @@ it can lead to memory exhaustion, increase at your own risk. ### Frontend +These settings are unset by default: an unset setting is omitted from the `/config` payload and the frontend then falls back on its deprecated `NEXT_PUBLIC_*` build-time variable (if any), then on its built-in default. Setting a value here always takes precedence over the frontend fallbacks. + | Variable | Default | Description | Required | |----------|---------|-------------|----------| -| `FRONTEND_THEME` | `white-label` | Theme for the frontend | Optional | | `FRONTEND_SILENT_LOGIN_ENABLED` | `False` | Whether silent login is enabled | Optional | +| `FRONTEND_THEME_CONFIG` | None (frontend defaults to `{"theme": "white-label"}`) | Theme configuration served to the frontend (`theme`, `terms_of_service_url`, `footer`), as JSON | Optional | +| `FRONTEND_FORCED_DEFAULT_LANGUAGE` | None (frontend defaults to `False`) | When `True`, the frontend default language fallback is `LANGUAGE_CODE` instead of the browser language | Optional | +| `FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB` | None (frontend defaults to `100`) | Chunk size in MB for frontend multipart uploads | Optional | +| `FRONTEND_HELP_CENTER_URL` | None | Help center URL | Optional | +| `FRONTEND_FEEDBACK_WIDGET_CONFIG` | None | Feedback widget configuration (`api_url`, `path`, `channel`, `home_channel`), as JSON | Optional | +| `FRONTEND_LAGAUFRE_WIDGET_CONFIG` | None | Lagaufre widget configuration (`api_url`, `path`), as JSON | Optional | + +Note: every language listed in `LANGUAGES` must have its translation files in the frontend (`/locales/*/xx-XX.json`), otherwise the UI falls back to `en-US`. ### Third-party Services diff --git a/env.d/development/backend.defaults b/env.d/development/backend.defaults index c20243fb..826c6532 100644 --- a/env.d/development/backend.defaults +++ b/env.d/development/backend.defaults @@ -83,7 +83,12 @@ KEYCLOAK_CLIENT_SECRET=ServiceAccountClientSecretForDev KEYCLOAK_GROUP_PATH_PREFIX=/maildomain- # Frontend -FRONTEND_THEME=dsfr +# FRONTEND_THEME_CONFIG={"theme": "white-label"} +# FRONTEND_FORCED_DEFAULT_LANGUAGE=false +# FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB=100 +# FRONTEND_HELP_CENTER_URL=https://help.example.com +# FRONTEND_FEEDBACK_WIDGET_CONFIG={"api_url": "", "path": "", "channel": "", "home_channel": ""} +# FRONTEND_LAGAUFRE_WIDGET_CONFIG={"api_url": "", "path": ""} # Messages MESSAGES_TESTDOMAIN=example.local diff --git a/env.d/development/frontend.defaults b/env.d/development/frontend.defaults index 64ab68d2..aacda217 100644 --- a/env.d/development/frontend.defaults +++ b/env.d/development/frontend.defaults @@ -1,20 +1,3 @@ +# The API origin is the only build-time variable left: it is needed to reach +# the backend /config endpoint that provides the rest of the configuration. NEXT_PUBLIC_API_ORIGIN=http://localhost:8901 -NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL= -NEXT_PUBLIC_FEEDBACK_WIDGET_PATH= -NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL= -NEXT_PUBLIC_FEEDBACK_WIDGET_HOME_CHANNEL= -NEXT_PUBLIC_HELP_CENTER_URL= -NEXT_PUBLIC_LANGUAGES=[["en-US","English"],["fr-FR","Français"],["nl-NL","Nederlands"]] -NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US -## Chunk size for multipart upload in MB -NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE=100 - -## Sentry -NEXT_PUBLIC_SENTRY_DSN= -NEXT_PUBLIC_SENTRY_ENVIRONMENT= - -## Theme Customization -NEXT_PUBLIC_THEME_CONFIG='{ - "theme": "white-label" -}' - diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 32f2b935..d377447c 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -203,10 +203,19 @@ "type": "string", "readOnly": true }, + "RELEASE": { + "type": "string", + "description": "Version of the application", + "readOnly": true + }, "LANGUAGES": { "type": "array", + "description": "Available languages, as (code, label) pairs", "items": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } }, "readOnly": true }, @@ -238,34 +247,29 @@ "description": "The URLs of the Drive external service.", "properties": { "sdk_url": { - "type": "string", - "readOnly": true + "type": "string" }, "api_url": { - "type": "string", - "readOnly": true + "type": "string" }, "file_url": { - "type": "string", - "readOnly": true + "type": "string" }, "preview_url": { - "type": "string", - "readOnly": true + "type": "string" }, "app_name": { - "type": "string", - "readOnly": true + "type": "string" } }, - "readOnly": true, "required": [ "sdk_url", "api_url", "file_url", "preview_url", "app_name" - ] + ], + "readOnly": true }, "SCHEMA_CUSTOM_ATTRIBUTES_USER": { "type": "object", @@ -280,16 +284,6 @@ "description": "Maximum size in bytes for outgoing email attachments", "readOnly": true }, - "MAX_OUTGOING_BODY_SIZE": { - "type": "integer", - "description": "Maximum size in bytes for outgoing email body (text + HTML)", - "readOnly": true - }, - "MAX_INCOMING_EMAIL_SIZE": { - "type": "integer", - "description": "Maximum size in bytes for incoming email (including attachments and body)", - "readOnly": true - }, "MAX_RECIPIENTS_PER_MESSAGE": { "type": "integer", "description": "Maximum number of recipients per message (to + cc + bcc)", @@ -330,10 +324,68 @@ "type": "boolean", "description": "Whether silent OIDC login is enabled", "readOnly": true + }, + "SENTRY_DSN": { + "type": "string", + "description": "Sentry DSN shared with the frontend", + "readOnly": true + }, + "FRONTEND_THEME_CONFIG": { + "type": "object", + "description": "Theme configuration for the frontend (theme, terms_of_service_url, footer)", + "readOnly": true + }, + "FRONTEND_FORCED_DEFAULT_LANGUAGE": { + "type": "boolean", + "description": "Whether the frontend should fall back to LANGUAGE_CODE instead of the browser language", + "readOnly": true + }, + "FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB": { + "type": "integer", + "description": "Chunk size in MB for frontend multipart uploads", + "readOnly": true + }, + "FRONTEND_HELP_CENTER_URL": { + "type": "string", + "description": "Help center URL", + "readOnly": true + }, + "FRONTEND_FEEDBACK_WIDGET_CONFIG": { + "type": "object", + "description": "Configuration of the feedback widget", + "properties": { + "api_url": { + "type": "string" + }, + "path": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "home_channel": { + "type": "string" + } + }, + "readOnly": true + }, + "FRONTEND_LAGAUFRE_WIDGET_CONFIG": { + "type": "object", + "description": "Configuration of the Lagaufre widget", + "properties": { + "api_url": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "readOnly": true } }, "required": [ "ENVIRONMENT", + "RELEASE", "LANGUAGES", "LANGUAGE_CODE", "AI_ENABLED", @@ -343,8 +395,6 @@ "SCHEMA_CUSTOM_ATTRIBUTES_USER", "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN", "MAX_OUTGOING_ATTACHMENT_SIZE", - "MAX_OUTGOING_BODY_SIZE", - "MAX_INCOMING_EMAIL_SIZE", "MAX_RECIPIENTS_PER_MESSAGE", "MAX_TEMPLATE_IMAGE_SIZE", "IMAGE_PROXY_ENABLED", diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index f5fa054b..f73b5378 100644 --- a/src/backend/core/api/viewsets/config.py +++ b/src/backend/core/api/viewsets/config.py @@ -1,5 +1,8 @@ """API ViewSet for sharing some public settings.""" +from dataclasses import dataclass, field +from typing import Any, Callable + from django.conf import settings import rest_framework as drf @@ -10,6 +13,216 @@ from core.ai.utils import is_ai_enabled, is_ai_summary_enabled, is_auto_labels_e from core.services.identity.keycloak import is_mandatory_totp_enabled +def _get_drive_config(): + """Build the Drive external service URLs, or None when not configured.""" + base_url = settings.DRIVE_CONFIG.get("base_url") + if not base_url: + return None + return { + "sdk_url": f"{base_url}{settings.DRIVE_CONFIG.get('sdk_url')}", + "api_url": f"{base_url}{settings.DRIVE_CONFIG.get('api_url')}", + "file_url": f"{base_url}{settings.DRIVE_CONFIG.get('file_url')}", + "preview_url": f"{base_url}{settings.DRIVE_CONFIG.get('preview_url')}", + "app_name": settings.DRIVE_CONFIG.get("app_name"), + } + + +@dataclass(frozen=True) +class ConfigEntry: + """A public setting exposed to the frontend through the config endpoint.""" + + key: str + schema: dict = field(default_factory=lambda: {"type": "string"}) + # Defaults to reading the setting named `key`; entries whose value is + # computed (feature flags, composed URLs) provide their own getter. + getter: Callable[[], Any] | None = None + # Non-required entries are omitted from the response when their value + # is None, and generated as optional fields in the API client. + required: bool = True + + def resolve(self): + """Return the value to expose for this entry.""" + if self.getter is not None: + return self.getter() + return getattr(settings, self.key, None) + + +CONFIG_ENTRIES = ( + ConfigEntry("ENVIRONMENT"), + ConfigEntry( + "RELEASE", + {"type": "string", "description": "Version of the application"}, + ), + ConfigEntry( + "LANGUAGES", + { + "type": "array", + "description": "Available languages, as (code, label) pairs", + "items": {"type": "array", "items": {"type": "string"}}, + }, + ), + ConfigEntry("LANGUAGE_CODE"), + ConfigEntry("AI_ENABLED", {"type": "boolean"}, getter=is_ai_enabled), + ConfigEntry( + "FEATURE_AI_SUMMARY", {"type": "boolean"}, getter=is_ai_summary_enabled + ), + ConfigEntry( + "FEATURE_AI_AUTOLABELS", {"type": "boolean"}, getter=is_auto_labels_enabled + ), + ConfigEntry( + "FEATURE_MAILBOX_ADMIN_CHANNELS", {"type": "array", "items": {"type": "string"}} + ), + ConfigEntry( + "DRIVE", + { + "type": "object", + "description": "The URLs of the Drive external service.", + "properties": { + "sdk_url": {"type": "string"}, + "api_url": {"type": "string"}, + "file_url": {"type": "string"}, + "preview_url": {"type": "string"}, + "app_name": {"type": "string"}, + }, + "required": ["sdk_url", "api_url", "file_url", "preview_url", "app_name"], + }, + getter=_get_drive_config, + required=False, + ), + ConfigEntry("SCHEMA_CUSTOM_ATTRIBUTES_USER", {"type": "object"}), + ConfigEntry("SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN", {"type": "object"}), + ConfigEntry( + "MAX_OUTGOING_ATTACHMENT_SIZE", + { + "type": "integer", + "description": "Maximum size in bytes for outgoing email attachments", + }, + ), + ConfigEntry( + "MAX_RECIPIENTS_PER_MESSAGE", + { + "type": "integer", + "description": "Maximum number of recipients per message (to + cc + bcc)", + }, + ), + ConfigEntry( + "MAX_TEMPLATE_IMAGE_SIZE", + { + "type": "integer", + "description": ( + "Maximum size in bytes for images embedded in templates and signatures" + ), + }, + ), + ConfigEntry( + "IMAGE_PROXY_ENABLED", + { + "type": "boolean", + "description": "Whether external images should be proxied", + }, + ), + ConfigEntry("FEATURE_MAILDOMAIN_CREATE", {"type": "boolean"}), + ConfigEntry("FEATURE_MAILDOMAIN_MANAGE_ACCESSES", {"type": "boolean"}), + ConfigEntry("FEATURE_THREAD_SPLIT", {"type": "boolean"}), + # Expose the *effective* mandatory-TOTP capability rather than the raw + # flag: the feature also requires IDENTITY_PROVIDER == "keycloak" and a + # populated KEYCLOAK_TOTP_ROLE_ID. Surfacing the raw flag would let the + # frontend render TOTP affordances that the backend silently refuses. + ConfigEntry( + "FEATURE_MAILDOMAIN_MANAGE_TOTP", + {"type": "boolean"}, + getter=is_mandatory_totp_enabled, + ), + ConfigEntry( + "MESSAGES_MANUAL_RETRY_MAX_AGE", + { + "type": "integer", + "description": ( + "Maximum age in seconds for a message to be eligible " + "for manual retry of failed deliveries" + ), + }, + ), + ConfigEntry( + "FRONTEND_SILENT_LOGIN_ENABLED", + { + "type": "boolean", + "description": "Whether silent OIDC login is enabled", + }, + ), + ConfigEntry( + "SENTRY_DSN", + {"type": "string", "description": "Sentry DSN shared with the frontend"}, + required=False, + ), + # The FRONTEND_* entries below must stay optional (omitted when the + # backend setting is unset) as long as the frontend keeps its deprecated + # NEXT_PUBLIC_* fallbacks: sending a backend default here would silently + # override those build-time variables. + ConfigEntry( + "FRONTEND_THEME_CONFIG", + { + "type": "object", + "description": ( + "Theme configuration for the frontend " + "(theme, terms_of_service_url, footer)" + ), + }, + required=False, + ), + ConfigEntry( + "FRONTEND_FORCED_DEFAULT_LANGUAGE", + { + "type": "boolean", + "description": ( + "Whether the frontend should fall back to LANGUAGE_CODE " + "instead of the browser language" + ), + }, + required=False, + ), + ConfigEntry( + "FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB", + { + "type": "integer", + "description": "Chunk size in MB for frontend multipart uploads", + }, + required=False, + ), + ConfigEntry( + "FRONTEND_HELP_CENTER_URL", + {"type": "string", "description": "Help center URL"}, + required=False, + ), + ConfigEntry( + "FRONTEND_FEEDBACK_WIDGET_CONFIG", + { + "type": "object", + "description": "Configuration of the feedback widget", + "properties": { + "api_url": {"type": "string"}, + "path": {"type": "string"}, + "channel": {"type": "string"}, + "home_channel": {"type": "string"}, + }, + }, + required=False, + ), + ConfigEntry( + "FRONTEND_LAGAUFRE_WIDGET_CONFIG", + { + "type": "object", + "description": "Configuration of the Lagaufre widget", + "properties": { + "api_url": {"type": "string"}, + "path": {"type": "string"}, + }, + }, + required=False, + ), +) + + class ConfigView(drf.views.APIView): """API ViewSet for sharing some public settings.""" @@ -23,157 +236,11 @@ class ConfigView(drf.views.APIView): response={ "type": "object", "properties": { - "ENVIRONMENT": {"type": "string", "readOnly": True}, - "LANGUAGES": { - "type": "array", - "items": {"type": "string"}, - "readOnly": True, - }, - "LANGUAGE_CODE": {"type": "string", "readOnly": True}, - "AI_ENABLED": {"type": "boolean", "readOnly": True}, - "FEATURE_AI_SUMMARY": { - "type": "boolean", - "readOnly": True, - }, - "FEATURE_AI_AUTOLABELS": { - "type": "boolean", - "readOnly": True, - }, - "FEATURE_MAILBOX_ADMIN_CHANNELS": { - "type": "array", - "items": {"type": "string"}, - "readOnly": True, - }, - "DRIVE": { - "type": "object", - "description": "The URLs of the Drive external service.", - "properties": { - "sdk_url": { - "type": "string", - "readOnly": True, - }, - "api_url": { - "type": "string", - "readOnly": True, - }, - "file_url": { - "type": "string", - "readOnly": True, - }, - "preview_url": { - "type": "string", - "readOnly": True, - }, - "app_name": { - "type": "string", - "readOnly": True, - }, - }, - "readOnly": True, - "required": [ - "sdk_url", - "api_url", - "file_url", - "preview_url", - "app_name", - ], - }, - "SCHEMA_CUSTOM_ATTRIBUTES_USER": { - "type": "object", - "readOnly": True, - }, - "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN": { - "type": "object", - "readOnly": True, - }, - "MAX_OUTGOING_ATTACHMENT_SIZE": { - "type": "integer", - "description": "Maximum size in bytes for outgoing email attachments", - "readOnly": True, - }, - "MAX_OUTGOING_BODY_SIZE": { - "type": "integer", - "description": "Maximum size in bytes for outgoing email body (text + HTML)", - "readOnly": True, - }, - "MAX_INCOMING_EMAIL_SIZE": { - "type": "integer", - "description": ( - "Maximum size in bytes for incoming email " - "(including attachments and body)" - ), - "readOnly": True, - }, - "MAX_RECIPIENTS_PER_MESSAGE": { - "type": "integer", - "description": ( - "Maximum number of recipients per message " - "(to + cc + bcc)" - ), - "readOnly": True, - }, - "MAX_TEMPLATE_IMAGE_SIZE": { - "type": "integer", - "description": "Maximum size in bytes for images embedded in templates and signatures", - "readOnly": True, - }, - "IMAGE_PROXY_ENABLED": { - "type": "boolean", - "description": "Whether external images should be proxied", - "readOnly": True, - }, - "FEATURE_MAILDOMAIN_CREATE": { - "type": "boolean", - "readOnly": True, - }, - "FEATURE_MAILDOMAIN_MANAGE_ACCESSES": { - "type": "boolean", - "readOnly": True, - }, - "FEATURE_THREAD_SPLIT": { - "type": "boolean", - "readOnly": True, - }, - "FEATURE_MAILDOMAIN_MANAGE_TOTP": { - "type": "boolean", - "readOnly": True, - }, - "MESSAGES_MANUAL_RETRY_MAX_AGE": { - "type": "integer", - "description": ( - "Maximum age in seconds for a message to be eligible " - "for manual retry of failed deliveries" - ), - "readOnly": True, - }, - "FRONTEND_SILENT_LOGIN_ENABLED": { - "type": "boolean", - "description": "Whether silent OIDC login is enabled", - "readOnly": True, - }, + entry.key: {**entry.schema, "readOnly": True} + for entry in CONFIG_ENTRIES }, "required": [ - "ENVIRONMENT", - "LANGUAGES", - "LANGUAGE_CODE", - "AI_ENABLED", - "FEATURE_AI_SUMMARY", - "FEATURE_AI_AUTOLABELS", - "FEATURE_MAILBOX_ADMIN_CHANNELS", - "SCHEMA_CUSTOM_ATTRIBUTES_USER", - "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN", - "MAX_OUTGOING_ATTACHMENT_SIZE", - "MAX_OUTGOING_BODY_SIZE", - "MAX_INCOMING_EMAIL_SIZE", - "MAX_RECIPIENTS_PER_MESSAGE", - "MAX_TEMPLATE_IMAGE_SIZE", - "IMAGE_PROXY_ENABLED", - "FEATURE_MAILDOMAIN_CREATE", - "FEATURE_MAILDOMAIN_MANAGE_ACCESSES", - "FEATURE_THREAD_SPLIT", - "FEATURE_MAILDOMAIN_MANAGE_TOTP", - "MESSAGES_MANUAL_RETRY_MAX_AGE", - "FRONTEND_SILENT_LOGIN_ENABLED", + entry.key for entry in CONFIG_ENTRIES if entry.required ], }, ) @@ -185,55 +252,10 @@ class ConfigView(drf.views.APIView): GET /api/v1.0/config/ Return a dictionary of public settings. """ - array_settings = [ - "ENVIRONMENT", - "LANGUAGES", - "LANGUAGE_CODE", - "SCHEMA_CUSTOM_ATTRIBUTES_USER", - "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN", - "MAX_TEMPLATE_IMAGE_SIZE", - "IMAGE_PROXY_ENABLED", - "MESSAGES_MANUAL_RETRY_MAX_AGE", - "FEATURE_MAILBOX_ADMIN_CHANNELS", - "FEATURE_MAILDOMAIN_CREATE", - "FEATURE_MAILDOMAIN_MANAGE_ACCESSES", - "FEATURE_THREAD_SPLIT", - "MAX_OUTGOING_ATTACHMENT_SIZE", - "MAX_OUTGOING_BODY_SIZE", - "MAX_INCOMING_EMAIL_SIZE", - "MAX_RECIPIENTS_PER_MESSAGE", - "FRONTEND_SILENT_LOGIN_ENABLED", - ] - dict_settings = {} - for setting in array_settings: - if hasattr(settings, setting): - dict_settings[setting] = getattr(settings, setting) - - # Expose the *effective* mandatory-TOTP capability rather than the raw - # flag: the feature also requires IDENTITY_PROVIDER == "keycloak" and a - # populated KEYCLOAK_TOTP_ROLE_ID. Surfacing the raw flag would let the - # frontend render TOTP affordances that the backend silently refuses. - dict_settings["FEATURE_MAILDOMAIN_MANAGE_TOTP"] = is_mandatory_totp_enabled() - - # AI Features - dict_settings["AI_ENABLED"] = is_ai_enabled() - dict_settings["FEATURE_AI_SUMMARY"] = is_ai_summary_enabled() - dict_settings["FEATURE_AI_AUTOLABELS"] = is_auto_labels_enabled() - - # Drive service - if base_url := settings.DRIVE_CONFIG.get("base_url"): - dict_settings.update( - { - "DRIVE": { - "sdk_url": f"{base_url}{settings.DRIVE_CONFIG.get('sdk_url')}", - "api_url": f"{base_url}{settings.DRIVE_CONFIG.get('api_url')}", - "file_url": f"{base_url}{settings.DRIVE_CONFIG.get('file_url')}", - "preview_url": ( - f"{base_url}{settings.DRIVE_CONFIG.get('preview_url')}" - ), - "app_name": settings.DRIVE_CONFIG.get("app_name"), - } - } - ) - - return drf.response.Response(dict_settings) + return drf.response.Response( + { + entry.key: value + for entry in CONFIG_ENTRIES + if (value := entry.resolve()) is not None or entry.required + } + ) diff --git a/src/backend/core/mda/inbound_tasks.py b/src/backend/core/mda/inbound_tasks.py index 462348a6..80d5ec71 100644 --- a/src/backend/core/mda/inbound_tasks.py +++ b/src/backend/core/mda/inbound_tasks.py @@ -194,9 +194,7 @@ def _retry_or_abandon( # skips it instead of deleting and losing the only copy of the mail. inbound_message.error_message = reason inbound_message.abandoned_at = timezone.now() - inbound_message.save( - update_fields=["error_message", "abandoned_at", "updated_at"] - ) + inbound_message.save(update_fields=["error_message", "abandoned_at", "updated_at"]) return { "success": False, "inbound_message_id": str(inbound_message.id), diff --git a/src/backend/core/tests/api/test_config.py b/src/backend/core/tests/api/test_config.py index 607c6e1b..f5ef05bd 100644 --- a/src/backend/core/tests/api/test_config.py +++ b/src/backend/core/tests/api/test_config.py @@ -31,12 +31,12 @@ pytestmark = pytest.mark.django_db DRIVE_CONFIG={"base_url": None, "app_name": "Drive"}, MAX_OUTGOING_ATTACHMENT_SIZE=20971520, # 20MB MAX_OUTGOING_BODY_SIZE=5242880, # 5MB - MAX_INCOMING_EMAIL_SIZE=10485760, # 10MB MAX_RECIPIENTS_PER_MESSAGE=42, MAX_TEMPLATE_IMAGE_SIZE=2097152, # 2MB IMAGE_PROXY_ENABLED=False, MESSAGES_MANUAL_RETRY_MAX_AGE=86400, # 1 day in seconds FRONTEND_SILENT_LOGIN_ENABLED=True, + RELEASE="1.2.3", ) @pytest.mark.parametrize("is_authenticated", [False, True]) def test_api_config(is_authenticated): @@ -51,6 +51,7 @@ def test_api_config(is_authenticated): assert response.status_code == HTTP_200_OK assert response.json() == { "ENVIRONMENT": "test", + "RELEASE": "1.2.3", "LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]], "LANGUAGE_CODE": "en-us", "AI_ENABLED": False, @@ -63,15 +64,25 @@ def test_api_config(is_authenticated): "FEATURE_MAILDOMAIN_MANAGE_TOTP": False, "SCHEMA_CUSTOM_ATTRIBUTES_USER": {}, "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN": {}, - "MAX_INCOMING_EMAIL_SIZE": 10485760, "MAX_OUTGOING_ATTACHMENT_SIZE": 20971520, - "MAX_OUTGOING_BODY_SIZE": 5242880, "MAX_RECIPIENTS_PER_MESSAGE": 42, "MAX_TEMPLATE_IMAGE_SIZE": 2097152, "IMAGE_PROXY_ENABLED": False, "MESSAGES_MANUAL_RETRY_MAX_AGE": 86400, "FRONTEND_SILENT_LOGIN_ENABLED": True, } + # Optional settings left unconfigured must be omitted, not null nor + # defaulted: the frontend falls back on its deprecated NEXT_PUBLIC_* + # variables when a key is absent, so sending a backend default here + # would silently override them. + assert "SENTRY_DSN" not in response.json() + assert "DRIVE" not in response.json() + assert "FRONTEND_THEME_CONFIG" not in response.json() + assert "FRONTEND_FORCED_DEFAULT_LANGUAGE" not in response.json() + assert "FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB" not in response.json() + assert "FRONTEND_HELP_CENTER_URL" not in response.json() + assert "FRONTEND_FEEDBACK_WIDGET_CONFIG" not in response.json() + assert "FRONTEND_LAGAUFRE_WIDGET_CONFIG" not in response.json() @override_settings( @@ -99,6 +110,44 @@ def test_api_config_with_external_services(): } +@override_settings( + SENTRY_DSN="https://public@sentry.example.com/1", + FRONTEND_THEME_CONFIG={"theme": "dsfr", "terms_of_service_url": "https://tos"}, + FRONTEND_FORCED_DEFAULT_LANGUAGE=False, + FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB=50, + FRONTEND_HELP_CENTER_URL="https://help.example.com", + FRONTEND_FEEDBACK_WIDGET_CONFIG={ + "api_url": "https://feedback.example.com", + "path": "https://feedback.example.com/static/", + "channel": "support", + "home_channel": "home", + }, + FRONTEND_LAGAUFRE_WIDGET_CONFIG={ + "api_url": "https://lagaufre.example.com", + "path": "https://lagaufre.example.com/static/", + }, +) +def test_api_config_frontend_settings(): + """Frontend settings configured on the backend should be exposed as-is.""" + response = APIClient().get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + config = response.json() + assert config["SENTRY_DSN"] == "https://public@sentry.example.com/1" + assert config["FRONTEND_THEME_CONFIG"] == { + "theme": "dsfr", + "terms_of_service_url": "https://tos", + } + # An explicit False must be sent: the backend value takes precedence + # over the frontend fallbacks, even when it equals their default. + assert config["FRONTEND_FORCED_DEFAULT_LANGUAGE"] is False + assert config["FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB"] == 50 + assert config["FRONTEND_HELP_CENTER_URL"] == "https://help.example.com" + assert config["FRONTEND_FEEDBACK_WIDGET_CONFIG"]["channel"] == "support" + assert config["FRONTEND_LAGAUFRE_WIDGET_CONFIG"]["api_url"] == ( + "https://lagaufre.example.com" + ) + + @override_settings( FEATURE_MAILDOMAIN_MANAGE_TOTP=True, KEYCLOAK_TOTP_ROLE_ID=None, diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index ed48daa6..f84213d1 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -224,6 +224,27 @@ class JSONValue(values.Value): raise ImproperlyConfigured(f"{name} is not valid JSON") from None +class OptionalBooleanValue(values.BooleanValue): + """ + A BooleanValue that also accepts ``None`` as default, to distinguish a + setting deliberately left unconfigured from an explicit True/False. + """ + + def __init__(self, *args, **kwargs): + default = kwargs.get("default", args[0] if args else None) + if default is None: + # BooleanValue's __init__ rejects a None default: initialize + # with a placeholder then restore None afterwards. + if args: + args = (False, *args[1:]) + else: + kwargs["default"] = False + super().__init__(*args, **kwargs) + self.default = None + else: + super().__init__(*args, **kwargs) + + class ThrottleRateValue(values.Value): """ A custom value class that parses and validates throttle rate strings diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 422cbbb0..7f697e7e 100644 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -21,7 +21,7 @@ import sentry_sdk from configurations import Configuration, values from sentry_sdk.integrations.django import DjangoIntegration -from core.utils import JSONValue, ThrottleRateValue +from core.utils import JSONValue, OptionalBooleanValue, ThrottleRateValue logger = logging.getLogger(__name__) @@ -752,8 +752,8 @@ class Base(Configuration): LANGUAGES = values.SingleNestedTupleValue( ( ("en-us", "English"), - ("fr-fr", "French"), - ("nl-nl", "Dutch"), + ("fr-fr", "Français"), + ("nl-nl", "Nederlands"), ) ) @@ -909,12 +909,37 @@ class Base(Configuration): SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None) # Frontend - FRONTEND_THEME = values.Value( - None, environ_name="FRONTEND_THEME", environ_prefix=None - ) + # These settings default to None ("not configured") on purpose: the config + # endpoint omits unset entries so the frontend can still fall back on its + # deprecated NEXT_PUBLIC_* build-time variables during the migration. + # Sending backend defaults instead would silently override those variables. FRONTEND_SILENT_LOGIN_ENABLED = values.BooleanValue( default=False, environ_name="FRONTEND_SILENT_LOGIN_ENABLED", environ_prefix=None ) + FRONTEND_THEME_CONFIG = JSONValue( + None, environ_name="FRONTEND_THEME_CONFIG", environ_prefix=None + ) + FRONTEND_FORCED_DEFAULT_LANGUAGE = OptionalBooleanValue( + None, + environ_name="FRONTEND_FORCED_DEFAULT_LANGUAGE", + environ_prefix=None, + ) + FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB = values.PositiveIntegerValue( + None, + environ_name="FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB", + environ_prefix=None, + ) + FRONTEND_HELP_CENTER_URL = values.Value( + None, environ_name="FRONTEND_HELP_CENTER_URL", environ_prefix=None + ) + # Expected keys: api_url, path, channel, home_channel + FRONTEND_FEEDBACK_WIDGET_CONFIG = JSONValue( + None, environ_name="FRONTEND_FEEDBACK_WIDGET_CONFIG", environ_prefix=None + ) + # Expected keys: api_url, path + FRONTEND_LAGAUFRE_WIDGET_CONFIG = JSONValue( + None, environ_name="FRONTEND_LAGAUFRE_WIDGET_CONFIG", environ_prefix=None + ) # Celery CELERY_BROKER_URL = values.Value( @@ -1308,7 +1333,7 @@ class Base(Configuration): f"Invalid MTA_OUT_SMTP_TLS_SECURITY_LEVEL: {self.MTA_OUT_SMTP_TLS_SECURITY_LEVEL}" ) - # OIDC Deprecated fields mapping + # Deprecated fields mapping deprecated_settings_mapping = ( ( "USER_OIDC_ESSENTIAL_CLAIMS", @@ -1321,6 +1346,9 @@ class Base(Configuration): values.ListValue(), ), ("USER_OIDC_FIELD_TO_SHORTNAME", None, None), + # FRONTEND_THEME was never honored; FRONTEND_THEME_CONFIG is the + # intentional replacement, so the old value is not mapped over. + ("FRONTEND_THEME", None, None), ) for deprecated_setting, new_setting, Parser in deprecated_settings_mapping: diff --git a/src/frontend/i18next.config.ts b/src/frontend/i18next.config.ts index 6d2d0c8c..c256b75d 100644 --- a/src/frontend/i18next.config.ts +++ b/src/frontend/i18next.config.ts @@ -1,8 +1,8 @@ import { defineConfig } from 'i18next-cli'; -import { LANGUAGES_ALLOWED } from './src/features/i18n/conf'; +import { SUPPORTED_LOCALES } from './src/features/i18n/conf'; export default defineConfig({ - locales: LANGUAGES_ALLOWED, + locales: SUPPORTED_LOCALES, extract: { defaultNS: "common", input: ['src/**/*.{js,jsx,ts,tsx}'], diff --git a/src/frontend/instrumentation-client.ts b/src/frontend/instrumentation-client.ts deleted file mode 100644 index 887dfe8a..00000000 --- a/src/frontend/instrumentation-client.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as Sentry from "@sentry/react"; - -const isSentryEnabled = import.meta.env.NEXT_PUBLIC_SENTRY_DSN && import.meta.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT; - -if (isSentryEnabled) { - Sentry.init({ - dsn: import.meta.env.NEXT_PUBLIC_SENTRY_DSN, - environment: import.meta.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, - }); - Sentry.setTag("application", "frontend"); -} diff --git a/src/frontend/src/bootstrap.tsx b/src/frontend/src/bootstrap.tsx new file mode 100644 index 00000000..1cdfa9a8 --- /dev/null +++ b/src/frontend/src/bootstrap.tsx @@ -0,0 +1,67 @@ +import { createRoot } from "react-dom/client"; +import { createRouter, parseSearchWith, RouterProvider, stringifySearchWith } from "@tanstack/react-router"; + +import { routeTree } from "./routes.gen"; +import { configRetrieve, configRetrieveResponse, getConfigRetrieveQueryKey } from "@/features/api/gen"; +import { queryClient } from "@/features/api/query-client"; +import { resolveConfig } from "@/features/config/resolve"; +import { initI18n } from "@/features/i18n/initI18n"; +import { installThemeFavicons } from "@/features/providers/theme-favicons"; +import { initSentry } from "@/features/sentry"; +import { handle } from '@/features/utils/errors'; + +// Default TSR encoding JSON-wraps every search value (`?key=1` → `?key=%221%22`). +// The rest of the app builds URLs via `URLSearchParams.toString()` and the +// backend expects plain values, so we plug identity parsers to keep both sides +// aligned — values stay as raw strings on the way out and on the way back. +const router = createRouter({ + routeTree, + scrollRestoration: false, + defaultPreload: false, + parseSearch: parseSearchWith((value) => value), + stringifySearch: stringifySearchWith((value) => (value == null ? "" : String(value))), +}); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +/** + * Fetch the backend configuration then initialize everything that must be + * ready before the first React render: Sentry, i18n and the theme favicons. + * The response also primes the React Query cache so `ConfigProvider` reads + * it without a second fetch. + */ +export const bootstrap = async () => { + const container = document.getElementById("root"); + if (!container) throw new Error("#root element not found in index.html"); + + try { + let response: configRetrieveResponse | undefined; + try { + response = await configRetrieve(); + } catch (error) { + // The app still boots on deprecated env vars and hardcoded defaults. + // The cache is intentionally left unprimed so ConfigProvider retries + // the fetch on mount and the React tree self-heals if the API is back. + console.error("[config] Failed to fetch the configuration, falling back to build-time defaults.", error); + } + + const config = resolveConfig(response?.data); + initSentry(config); + initI18n(config); + installThemeFavicons(config.THEME_CONFIG.theme); + if (response) { + queryClient.setQueryData(getConfigRetrieveQueryKey(), response); + } + + createRoot(container).render(); + } catch (error) { + // Last-resort safety net: this runs before the React ErrorBoundary exists. + handle(error); + container.innerHTML = + "

Something went wrong while starting the application. Please try again later.

"; + } +}; diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200.ts b/src/frontend/src/features/api/gen/models/config_retrieve200.ts index 7e8bf9b9..35db8c87 100644 --- a/src/frontend/src/features/api/gen/models/config_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/config_retrieve200.ts @@ -8,10 +8,16 @@ import type { ConfigRetrieve200DRIVE } from "./config_retrieve200_driv_e"; import type { ConfigRetrieve200SCHEMACUSTOMATTRIBUTESUSER } from "./config_retrieve200_schemacustomattributesuse_r"; import type { ConfigRetrieve200SCHEMACUSTOMATTRIBUTESMAILDOMAIN } from "./config_retrieve200_schemacustomattributesmaildomai_n"; +import type { ConfigRetrieve200FRONTENDTHEMECONFIG } from "./config_retrieve200_frontendthemeconfi_g"; +import type { ConfigRetrieve200FRONTENDFEEDBACKWIDGETCONFIG } from "./config_retrieve200_frontendfeedbackwidgetconfi_g"; +import type { ConfigRetrieve200FRONTENDLAGAUFREWIDGETCONFIG } from "./config_retrieve200_frontendlagaufrewidgetconfi_g"; export type ConfigRetrieve200 = { readonly ENVIRONMENT: string; - readonly LANGUAGES: readonly string[]; + /** Version of the application */ + readonly RELEASE: string; + /** Available languages, as (code, label) pairs */ + readonly LANGUAGES: readonly string[][]; readonly LANGUAGE_CODE: string; readonly AI_ENABLED: boolean; readonly FEATURE_AI_SUMMARY: boolean; @@ -23,10 +29,6 @@ export type ConfigRetrieve200 = { readonly SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN: ConfigRetrieve200SCHEMACUSTOMATTRIBUTESMAILDOMAIN; /** Maximum size in bytes for outgoing email attachments */ readonly MAX_OUTGOING_ATTACHMENT_SIZE: number; - /** Maximum size in bytes for outgoing email body (text + HTML) */ - readonly MAX_OUTGOING_BODY_SIZE: number; - /** Maximum size in bytes for incoming email (including attachments and body) */ - readonly MAX_INCOMING_EMAIL_SIZE: number; /** Maximum number of recipients per message (to + cc + bcc) */ readonly MAX_RECIPIENTS_PER_MESSAGE: number; /** Maximum size in bytes for images embedded in templates and signatures */ @@ -41,4 +43,18 @@ export type ConfigRetrieve200 = { readonly MESSAGES_MANUAL_RETRY_MAX_AGE: number; /** Whether silent OIDC login is enabled */ readonly FRONTEND_SILENT_LOGIN_ENABLED: boolean; + /** Sentry DSN shared with the frontend */ + readonly SENTRY_DSN?: string; + /** Theme configuration for the frontend (theme, terms_of_service_url, footer) */ + readonly FRONTEND_THEME_CONFIG?: ConfigRetrieve200FRONTENDTHEMECONFIG; + /** Whether the frontend should fall back to LANGUAGE_CODE instead of the browser language */ + readonly FRONTEND_FORCED_DEFAULT_LANGUAGE?: boolean; + /** Chunk size in MB for frontend multipart uploads */ + readonly FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB?: number; + /** Help center URL */ + readonly FRONTEND_HELP_CENTER_URL?: string; + /** Configuration of the feedback widget */ + readonly FRONTEND_FEEDBACK_WIDGET_CONFIG?: ConfigRetrieve200FRONTENDFEEDBACKWIDGETCONFIG; + /** Configuration of the Lagaufre widget */ + readonly FRONTEND_LAGAUFRE_WIDGET_CONFIG?: ConfigRetrieve200FRONTENDLAGAUFREWIDGETCONFIG; }; diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200_frontendfeedbackwidgetconfi_g.ts b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendfeedbackwidgetconfi_g.ts new file mode 100644 index 00000000..0bf8fdd3 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendfeedbackwidgetconfi_g.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Configuration of the feedback widget + */ +export type ConfigRetrieve200FRONTENDFEEDBACKWIDGETCONFIG = { + readonly api_url?: string; + readonly path?: string; + readonly channel?: string; + readonly home_channel?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200_frontendlagaufrewidgetconfi_g.ts b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendlagaufrewidgetconfi_g.ts new file mode 100644 index 00000000..228b2d51 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendlagaufrewidgetconfi_g.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Configuration of the Lagaufre widget + */ +export type ConfigRetrieve200FRONTENDLAGAUFREWIDGETCONFIG = { + readonly api_url?: string; + readonly path?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200_frontendthemeconfi_g.ts b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendthemeconfi_g.ts new file mode 100644 index 00000000..3c5b956b --- /dev/null +++ b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendthemeconfi_g.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Theme configuration for the frontend (theme, terms_of_service_url, footer) + */ +export type ConfigRetrieve200FRONTENDTHEMECONFIG = { [key: string]: unknown }; diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index ee07f95c..15965eef 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -25,6 +25,9 @@ export * from "./channel_create_response"; export * from "./channel_request"; export * from "./config_retrieve200"; export * from "./config_retrieve200_driv_e"; +export * from "./config_retrieve200_frontendfeedbackwidgetconfi_g"; +export * from "./config_retrieve200_frontendlagaufrewidgetconfi_g"; +export * from "./config_retrieve200_frontendthemeconfi_g"; export * from "./config_retrieve200_schemacustomattributesmaildomai_n"; export * from "./config_retrieve200_schemacustomattributesuse_r"; export * from "./contact"; diff --git a/src/frontend/src/features/api/query-client.tsx b/src/frontend/src/features/api/query-client.tsx new file mode 100644 index 00000000..3bd3e0f0 --- /dev/null +++ b/src/frontend/src/features/api/query-client.tsx @@ -0,0 +1,38 @@ +import { + MutationCache, + Query, + QueryCache, + QueryClient, +} from "@tanstack/react-query"; + +import { addToast, ToasterItem } from "@/features/ui/components/toaster"; +import { errorToString } from "@/features/api/api-error"; + +const onError = (error: Error, query: unknown) => { + if ((query as Query).meta?.noGlobalError) { + return; + } + addToast( + + {errorToString(error)} + , + { + toastId: "APPLICATION_ERROR_TOAST", + }, + ); +}; + +export const queryClient = new QueryClient({ + mutationCache: new MutationCache({ + onError: (error, _variables, _context, mutation) => onError(error, mutation), + }), + queryCache: new QueryCache({ + onError: (error, query) => onError(error, query), + }), + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/src/frontend/src/features/config/index.ts b/src/frontend/src/features/config/index.ts index 41fd2283..b678f6b3 100644 --- a/src/frontend/src/features/config/index.ts +++ b/src/frontend/src/features/config/index.ts @@ -1,7 +1,2 @@ - -export const getConfig = () => { - // TODO: Later, be based on URL query params for instance. - return { - - }; -}; +export * from "./constants"; +export * from "./resolve"; diff --git a/src/frontend/src/features/config/resolve.test.ts b/src/frontend/src/features/config/resolve.test.ts new file mode 100644 index 00000000..3bac8251 --- /dev/null +++ b/src/frontend/src/features/config/resolve.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConfigRetrieve200 } from "@/features/api/gen"; + +const API_CONFIG = { + ENVIRONMENT: "production", + RELEASE: "1.2.3", + LANGUAGES: [ + ["en-us", "English"], + ["fr-fr", "Français"], + ], + LANGUAGE_CODE: "fr-fr", + AI_ENABLED: true, + FEATURE_AI_SUMMARY: false, + FEATURE_AI_AUTOLABELS: false, + FEATURE_MAILBOX_ADMIN_CHANNELS: [], + SCHEMA_CUSTOM_ATTRIBUTES_USER: {}, + SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN: {}, + MAX_OUTGOING_ATTACHMENT_SIZE: 100, + MAX_OUTGOING_BODY_SIZE: 100, + MAX_RECIPIENTS_PER_MESSAGE: 10, + MAX_TEMPLATE_IMAGE_SIZE: 100, + IMAGE_PROXY_ENABLED: false, + FEATURE_MAILDOMAIN_CREATE: true, + FEATURE_MAILDOMAIN_MANAGE_ACCESSES: true, + FEATURE_MAILDOMAIN_MANAGE_TOTP: false, + FEATURE_THREAD_SPLIT: true, + MESSAGES_MANUAL_RETRY_MAX_AGE: 0, + FRONTEND_SILENT_LOGIN_ENABLED: false, + SENTRY_DSN: "https://public@sentry.example.com/1", + FRONTEND_THEME_CONFIG: { theme: "dsfr" }, + FRONTEND_FORCED_DEFAULT_LANGUAGE: true, + FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB: 42, + FRONTEND_HELP_CENTER_URL: "https://help.example.com", + FRONTEND_FEEDBACK_WIDGET_CONFIG: { + api_url: "https://feedback.example.com", + path: "https://feedback.example.com/static/", + channel: "support", + home_channel: "home", + }, + FRONTEND_LAGAUFRE_WIDGET_CONFIG: { + api_url: "https://lagaufre.example.com", + path: "https://lagaufre.example.com/static/", + }, +} as unknown as ConfigRetrieve200; + +// The module keeps a warn-once registry, so each test imports a fresh copy. +const importResolve = async () => await import("./resolve"); + +describe("resolveConfig", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("uses API values first and normalizes languages to BCP 47", async () => { + vi.stubEnv("NEXT_PUBLIC_HELP_CENTER_URL", "https://deprecated.example.com"); + const { resolveConfig } = await importResolve(); + + const config = resolveConfig(API_CONFIG); + + expect(config.LANGUAGES).toEqual([ + ["en-US", "English"], + ["fr-FR", "Français"], + ]); + expect(config.BASE_LANGUAGE).toBe("fr-FR"); + expect(config.IS_LANGUAGE_FORCED).toBe(true); + expect(config.THEME_CONFIG).toEqual({ theme: "dsfr" }); + expect(config.SENTRY_DSN).toBe("https://public@sentry.example.com/1"); + expect(config.SENTRY_ENVIRONMENT).toBe("production"); + expect(config.RELEASE).toBe("1.2.3"); + expect(config.MULTIPART_UPLOAD_CHUNK_SIZE_MB).toBe(42); + // API wins over the deprecated env var + expect(config.HELP_CENTER_URL).toBe("https://help.example.com"); + expect(config.FEEDBACK_WIDGET.channel).toBe("support"); + expect(config.LAGAUFRE_WIDGET.api_url).toBe("https://lagaufre.example.com"); + }); + + it("falls back on deprecated env vars when the API is unreachable", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubEnv("NEXT_PUBLIC_THEME_CONFIG", '{"theme": "anct"}'); + vi.stubEnv("NEXT_PUBLIC_LANGUAGES", '[["de-DE","Deutsch"]]'); + vi.stubEnv("NEXT_PUBLIC_DEFAULT_LANGUAGE", "de-DE"); + vi.stubEnv("NEXT_PUBLIC_FORCED_DEFAULT_LANGUAGE", "true"); + vi.stubEnv("NEXT_PUBLIC_SENTRY_DSN", "https://public@sentry.example.com/2"); + vi.stubEnv("NEXT_PUBLIC_SENTRY_ENVIRONMENT", "staging"); + vi.stubEnv("NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE", "50"); + vi.stubEnv("NEXT_PUBLIC_HELP_CENTER_URL", "https://help.example.com"); + vi.stubEnv("NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL", "support"); + const { resolveConfig } = await importResolve(); + + const config = resolveConfig(undefined); + + expect(config.THEME_CONFIG).toEqual({ theme: "anct" }); + expect(config.LANGUAGES).toEqual([["de-DE", "Deutsch"]]); + expect(config.BASE_LANGUAGE).toBe("de-DE"); + expect(config.IS_LANGUAGE_FORCED).toBe(true); + expect(config.SENTRY_DSN).toBe("https://public@sentry.example.com/2"); + expect(config.SENTRY_ENVIRONMENT).toBe("staging"); + expect(config.MULTIPART_UPLOAD_CHUNK_SIZE_MB).toBe(50); + expect(config.HELP_CENTER_URL).toBe("https://help.example.com"); + expect(config.FEEDBACK_WIDGET.channel).toBe("support"); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("NEXT_PUBLIC_THEME_CONFIG is deprecated"), + ); + }); + + it("falls back on deprecated env vars for keys the API omits", async () => { + // The backend omits FRONTEND_* settings left unconfigured so that it + // never overrides the deprecated env vars with its own defaults. + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubEnv("NEXT_PUBLIC_THEME_CONFIG", '{"theme": "anct"}'); + vi.stubEnv("NEXT_PUBLIC_FORCED_DEFAULT_LANGUAGE", "true"); + vi.stubEnv("NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE", "50"); + vi.stubEnv("NEXT_PUBLIC_HELP_CENTER_URL", "https://help.example.com"); + vi.stubEnv("NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL", "support"); + const { resolveConfig } = await importResolve(); + + const omittedKeys = [ + "FRONTEND_THEME_CONFIG", + "FRONTEND_FORCED_DEFAULT_LANGUAGE", + "FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB", + "FRONTEND_HELP_CENTER_URL", + "FRONTEND_FEEDBACK_WIDGET_CONFIG", + ]; + const partialApiConfig = Object.fromEntries( + Object.entries(API_CONFIG).filter(([key]) => !omittedKeys.includes(key)), + ) as ConfigRetrieve200; + const config = resolveConfig(partialApiConfig); + + expect(config.THEME_CONFIG).toEqual({ theme: "anct" }); + expect(config.IS_LANGUAGE_FORCED).toBe(true); + expect(config.MULTIPART_UPLOAD_CHUNK_SIZE_MB).toBe(50); + expect(config.HELP_CENTER_URL).toBe("https://help.example.com"); + expect(config.FEEDBACK_WIDGET.channel).toBe("support"); + }); + + it("warns only once per deprecated env var", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubEnv("NEXT_PUBLIC_HELP_CENTER_URL", "https://help.example.com"); + const { resolveConfig } = await importResolve(); + + resolveConfig(undefined); + resolveConfig(undefined); + + const helpCenterWarnings = warn.mock.calls.filter(([message]) => + String(message).includes("NEXT_PUBLIC_HELP_CENTER_URL"), + ); + expect(helpCenterWarnings).toHaveLength(1); + }); + + it("uses hardcoded defaults when neither API nor env vars are set", async () => { + const { resolveConfig, DEFAULT_LANGUAGES } = await importResolve(); + + const config = resolveConfig(undefined); + + expect(config.THEME_CONFIG).toEqual({ theme: "white-label" }); + expect(config.LANGUAGES).toEqual(DEFAULT_LANGUAGES); + expect(config.BASE_LANGUAGE).toBe("en-US"); + expect(config.IS_LANGUAGE_FORCED).toBe(false); + expect(config.SENTRY_DSN).toBeUndefined(); + expect(config.MULTIPART_UPLOAD_CHUNK_SIZE_MB).toBe(100); + expect(config.HELP_CENTER_URL).toBeUndefined(); + expect(config.FEEDBACK_WIDGET).toEqual({}); + expect(config.RELEASE).toBe("NA"); + }); + + it("ignores invalid JSON in deprecated env vars", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubEnv("NEXT_PUBLIC_THEME_CONFIG", "{invalid json"); + const { resolveConfig } = await importResolve(); + + const config = resolveConfig(undefined); + + expect(config.THEME_CONFIG).toEqual({ theme: "white-label" }); + }); + + it("treats empty deprecated env vars as unset", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubEnv("NEXT_PUBLIC_HELP_CENTER_URL", ""); + const { resolveConfig } = await importResolve(); + + const config = resolveConfig(undefined); + + expect(config.HELP_CENTER_URL).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe("toBCP47", () => { + it("normalizes region casing and keeps region-less codes", async () => { + const { toBCP47 } = await importResolve(); + expect(toBCP47("en-us")).toBe("en-US"); + expect(toBCP47("fr-FR")).toBe("fr-FR"); + expect(toBCP47("fr")).toBe("fr"); + }); +}); diff --git a/src/frontend/src/features/config/resolve.ts b/src/frontend/src/features/config/resolve.ts new file mode 100644 index 00000000..c9187038 --- /dev/null +++ b/src/frontend/src/features/config/resolve.ts @@ -0,0 +1,290 @@ +import { ConfigRetrieve200 } from "@/features/api/gen"; +import { FooterProps } from "@gouvfr-lasuite/ui-kit"; + +export type ThemeConfig = { + theme: "white-label" | "anct" | "dsfr"; + terms_of_service_url?: string; + footer?: FooterProps; +}; + +export type FeedbackWidgetConfig = { + api_url?: string; + path?: string; + channel?: string; + home_channel?: string; +}; + +export type LagaufreWidgetConfig = { + api_url?: string; + path?: string; +}; + +export type DriveConfig = NonNullable; + +/** + * The application configuration exposed to the whole app: the `/config` + * endpoint payload with frontend-specific keys already resolved (deprecated + * env var fallbacks applied, languages normalized to BCP 47, widget + * configurations grouped). + */ +export type AppConfig = Omit< + ConfigRetrieve200, + | "DRIVE" + | "LANGUAGES" + | "LANGUAGE_CODE" + | "SENTRY_DSN" + | "FRONTEND_THEME_CONFIG" + | "FRONTEND_FORCED_DEFAULT_LANGUAGE" + | "FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB" + | "FRONTEND_HELP_CENTER_URL" + | "FRONTEND_FEEDBACK_WIDGET_CONFIG" + | "FRONTEND_LAGAUFRE_WIDGET_CONFIG" +> & { + DRIVE: DriveConfig; + /** Available languages as (BCP 47 code, label) pairs. */ + LANGUAGES: [string, string][]; + /** Default language, as a BCP 47 code. */ + BASE_LANGUAGE: string; + /** When true, fall back to BASE_LANGUAGE instead of the browser language. */ + IS_LANGUAGE_FORCED: boolean; + SENTRY_DSN?: string; + SENTRY_ENVIRONMENT?: string; + THEME_CONFIG: ThemeConfig; + MULTIPART_UPLOAD_CHUNK_SIZE_MB: number; + HELP_CENTER_URL?: string; + FEEDBACK_WIDGET: FeedbackWidgetConfig; + LAGAUFRE_WIDGET: LagaufreWidgetConfig; +}; + +export const DEFAULT_LANGUAGES: [string, string][] = [ + ["en-US", "English"], + ["fr-FR", "Français"], + ["nl-NL", "Nederlands"], +]; + +const DEFAULT_DRIVE_CONFIG: DriveConfig = { + sdk_url: "", + api_url: "", + file_url: "", + preview_url: "", + app_name: "Drive", +}; + +const DEFAULT_THEME_CONFIG: ThemeConfig = { theme: "white-label" }; + +const DEFAULT_MULTIPART_UPLOAD_CHUNK_SIZE_MB = 100; + +/** + * Normalize a language code to its BCP 47 casing (`en-us` → `en-US`). + * The backend exposes Django-style lowercase codes while the locale files + * and the stored language are BCP 47. + */ +export const toBCP47 = (code: string): string => { + const [language, region] = code.split("-"); + return region + ? `${language.toLowerCase()}-${region.toUpperCase()}` + : language.toLowerCase(); +}; + +const warnedKeys = new Set(); + +/** + * Read a deprecated `NEXT_PUBLIC_*` build-time variable as a fallback for a + * backend-provided setting, warning once per variable. Empty strings are + * treated as unset, matching how these variables used to behave. + */ +const deprecatedEnv = ( + envKey: string, + replacement: string, + raw: string | undefined, +): string | undefined => { + if (!raw) return undefined; + if (!warnedKeys.has(envKey)) { + warnedKeys.add(envKey); + console.warn( + `[DEPRECATED] ${envKey} is deprecated and will be removed, ` + + `configure the backend setting ${replacement} instead.`, + ); + } + return raw; +}; + +const parseJSON = (envKey: string, raw: string | undefined): T | undefined => { + if (raw === undefined) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + console.warn(`[config] Ignoring ${envKey}: invalid JSON.`); + return undefined; + } +}; + +const parseIntOrUndefined = (raw: string | undefined): number | undefined => { + if (raw === undefined) return undefined; + const value = parseInt(raw, 10); + return Number.isNaN(value) ? undefined : value; +}; + +const resolveLanguages = (api?: ConfigRetrieve200): [string, string][] => { + if (api?.LANGUAGES) { + return api.LANGUAGES.map( + ([code, label]): [string, string] => [toBCP47(code), label], + ); + } + return ( + parseJSON<[string, string][]>( + "NEXT_PUBLIC_LANGUAGES", + deprecatedEnv( + "NEXT_PUBLIC_LANGUAGES", + "LANGUAGES", + import.meta.env.NEXT_PUBLIC_LANGUAGES, + ), + ) ?? DEFAULT_LANGUAGES + ); +}; + +const resolveThemeConfig = (api?: ConfigRetrieve200): ThemeConfig => { + if (api?.FRONTEND_THEME_CONFIG) { + return api.FRONTEND_THEME_CONFIG as ThemeConfig; + } + return ( + parseJSON( + "NEXT_PUBLIC_THEME_CONFIG", + deprecatedEnv( + "NEXT_PUBLIC_THEME_CONFIG", + "FRONTEND_THEME_CONFIG", + import.meta.env.NEXT_PUBLIC_THEME_CONFIG, + ), + ) ?? DEFAULT_THEME_CONFIG + ); +}; + +const resolveFeedbackWidget = (api?: ConfigRetrieve200): FeedbackWidgetConfig => { + if (api?.FRONTEND_FEEDBACK_WIDGET_CONFIG) { + return api.FRONTEND_FEEDBACK_WIDGET_CONFIG as FeedbackWidgetConfig; + } + return { + api_url: deprecatedEnv( + "NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL", + "FRONTEND_FEEDBACK_WIDGET_CONFIG", + import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL, + ), + path: deprecatedEnv( + "NEXT_PUBLIC_FEEDBACK_WIDGET_PATH", + "FRONTEND_FEEDBACK_WIDGET_CONFIG", + import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_PATH, + ), + channel: deprecatedEnv( + "NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL", + "FRONTEND_FEEDBACK_WIDGET_CONFIG", + import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL, + ), + home_channel: deprecatedEnv( + "NEXT_PUBLIC_FEEDBACK_WIDGET_HOME_CHANNEL", + "FRONTEND_FEEDBACK_WIDGET_CONFIG", + import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_HOME_CHANNEL, + ), + }; +}; + +const resolveLagaufreWidget = (api?: ConfigRetrieve200): LagaufreWidgetConfig => { + if (api?.FRONTEND_LAGAUFRE_WIDGET_CONFIG) { + return api.FRONTEND_LAGAUFRE_WIDGET_CONFIG as LagaufreWidgetConfig; + } + return { + api_url: deprecatedEnv( + "NEXT_PUBLIC_LAGAUFRE_WIDGET_API_URL", + "FRONTEND_LAGAUFRE_WIDGET_CONFIG", + import.meta.env.NEXT_PUBLIC_LAGAUFRE_WIDGET_API_URL, + ), + path: deprecatedEnv( + "NEXT_PUBLIC_LAGAUFRE_WIDGET_PATH", + "FRONTEND_LAGAUFRE_WIDGET_CONFIG", + import.meta.env.NEXT_PUBLIC_LAGAUFRE_WIDGET_PATH, + ), + }; +}; + +/** + * Build the application configuration from the `/config` endpoint payload. + * Every key resolves as: backend value → deprecated `NEXT_PUBLIC_*` env var + * (transition fallback) → hardcoded default, so the app can still boot when + * the backend is unreachable. + */ +export const resolveConfig = (api?: ConfigRetrieve200): AppConfig => { + const languages = resolveLanguages(api); + const baseLanguage = api?.LANGUAGE_CODE + ? toBCP47(api.LANGUAGE_CODE) + : (deprecatedEnv( + "NEXT_PUBLIC_DEFAULT_LANGUAGE", + "LANGUAGE_CODE", + import.meta.env.NEXT_PUBLIC_DEFAULT_LANGUAGE, + ) ?? languages[0][0]); + + return { + ENVIRONMENT: api?.ENVIRONMENT ?? "", + RELEASE: api?.RELEASE ?? "NA", + AI_ENABLED: api?.AI_ENABLED ?? false, + FEATURE_AI_SUMMARY: api?.FEATURE_AI_SUMMARY ?? false, + FEATURE_AI_AUTOLABELS: api?.FEATURE_AI_AUTOLABELS ?? false, + FEATURE_MAILBOX_ADMIN_CHANNELS: api?.FEATURE_MAILBOX_ADMIN_CHANNELS ?? [], + SCHEMA_CUSTOM_ATTRIBUTES_USER: api?.SCHEMA_CUSTOM_ATTRIBUTES_USER ?? {}, + SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN: + api?.SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN ?? {}, + MAX_OUTGOING_ATTACHMENT_SIZE: api?.MAX_OUTGOING_ATTACHMENT_SIZE ?? 20 * 1024 ** 2, + MAX_RECIPIENTS_PER_MESSAGE: api?.MAX_RECIPIENTS_PER_MESSAGE ?? 500, + MAX_TEMPLATE_IMAGE_SIZE: api?.MAX_TEMPLATE_IMAGE_SIZE ?? 2 * 1024 ** 2, + IMAGE_PROXY_ENABLED: api?.IMAGE_PROXY_ENABLED ?? false, + FEATURE_MAILDOMAIN_CREATE: api?.FEATURE_MAILDOMAIN_CREATE ?? true, + FEATURE_MAILDOMAIN_MANAGE_ACCESSES: + api?.FEATURE_MAILDOMAIN_MANAGE_ACCESSES ?? true, + FEATURE_MAILDOMAIN_MANAGE_TOTP: api?.FEATURE_MAILDOMAIN_MANAGE_TOTP ?? false, + FEATURE_THREAD_SPLIT: api?.FEATURE_THREAD_SPLIT ?? true, + MESSAGES_MANUAL_RETRY_MAX_AGE: api?.MESSAGES_MANUAL_RETRY_MAX_AGE ?? 7 * 24 * 60 ** 2, + FRONTEND_SILENT_LOGIN_ENABLED: api?.FRONTEND_SILENT_LOGIN_ENABLED ?? false, + DRIVE: api?.DRIVE ?? DEFAULT_DRIVE_CONFIG, + LANGUAGES: languages, + BASE_LANGUAGE: baseLanguage, + IS_LANGUAGE_FORCED: + api?.FRONTEND_FORCED_DEFAULT_LANGUAGE ?? + deprecatedEnv( + "NEXT_PUBLIC_FORCED_DEFAULT_LANGUAGE", + "FRONTEND_FORCED_DEFAULT_LANGUAGE", + import.meta.env.NEXT_PUBLIC_FORCED_DEFAULT_LANGUAGE, + ) === "true", + SENTRY_DSN: + api?.SENTRY_DSN ?? + deprecatedEnv( + "NEXT_PUBLIC_SENTRY_DSN", + "SENTRY_DSN", + import.meta.env.NEXT_PUBLIC_SENTRY_DSN, + ), + SENTRY_ENVIRONMENT: + api?.ENVIRONMENT ?? + deprecatedEnv( + "NEXT_PUBLIC_SENTRY_ENVIRONMENT", + "ENVIRONMENT (backend)", + import.meta.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, + ), + THEME_CONFIG: resolveThemeConfig(api), + MULTIPART_UPLOAD_CHUNK_SIZE_MB: + api?.FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB ?? + parseIntOrUndefined( + deprecatedEnv( + "NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE", + "FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB", + import.meta.env.NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE, + ), + ) ?? + DEFAULT_MULTIPART_UPLOAD_CHUNK_SIZE_MB, + HELP_CENTER_URL: + api?.FRONTEND_HELP_CENTER_URL ?? + deprecatedEnv( + "NEXT_PUBLIC_HELP_CENTER_URL", + "FRONTEND_HELP_CENTER_URL", + import.meta.env.NEXT_PUBLIC_HELP_CENTER_URL, + ), + FEEDBACK_WIDGET: resolveFeedbackWidget(api), + LAGAUFRE_WIDGET: resolveLagaufreWidget(api), + }; +}; diff --git a/src/frontend/src/features/controlled-modals/message-importer/use-bucket-upload.tsx b/src/frontend/src/features/controlled-modals/message-importer/use-bucket-upload.tsx index a69e6a8f..4a347124 100644 --- a/src/frontend/src/features/controlled-modals/message-importer/use-bucket-upload.tsx +++ b/src/frontend/src/features/controlled-modals/message-importer/use-bucket-upload.tsx @@ -1,13 +1,8 @@ import { fetchAPI } from "@/features/api/fetch-api"; +import { useConfig } from "@/features/providers/config"; import { handle } from "@/features/utils/errors"; import { useEffect, useMemo, useRef, useState } from "react"; - -// Threshold to use multipart upload (object storage allows chunks of 10MB at least) -const CHUNK_SIZE_MB = import.meta.env.NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE ? parseInt(import.meta.env.NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE) : 100; -const CHUNK_SIZE = CHUNK_SIZE_MB * 1024 * 1024; -const MULTIPART_THRESHOLD = CHUNK_SIZE; - interface PartUpload { PartNumber: number; ETag: string; @@ -172,6 +167,7 @@ const directUploadFile = ( */ const multiPartUploadFile = async ( file: File, + chunkSize: number, onUploadCreated: (args: string) => void, onUploadInit: (xhr: XMLHttpRequest) => void, onUploadCompleting: () => void, @@ -201,7 +197,7 @@ const multiPartUploadFile = async ( } // Step 2: Split file into chunks and upload each part - const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + const totalChunks = Math.ceil(file.size / chunkSize); let uploadedBytes = 0; // const parts: PartUpload[] = await Promise.all(Array.from({ length: totalChunks }, async (_, index) => { @@ -210,8 +206,8 @@ const multiPartUploadFile = async ( for (let index = 0; index < totalChunks; index++) { try { const partNumber = index + 1; - const start = index * CHUNK_SIZE; - const end = Math.min(start + CHUNK_SIZE, file.size); + const start = index * chunkSize; + const end = Math.min(start + chunkSize, file.size); const chunk = file.slice(start, end); const partResponse = await fetchAPI( @@ -281,6 +277,9 @@ const abortUpload = async (uploadId: string, filename: string) => { export const useBucketUpload = ( { onSuccess, onError }: { onSuccess?: (manager: BucketUploadManager) => void, onError?: (error: string) => void } ): BucketUploadManager => { + const { MULTIPART_UPLOAD_CHUNK_SIZE_MB } = useConfig(); + // Threshold to use multipart upload (object storage allows chunks of 10MB at least) + const chunkSize = MULTIPART_UPLOAD_CHUNK_SIZE_MB * 1024 * 1024; const [file, setFile] = useState(null); const uploadIdRef = useRef(null); const [state, setState] = useState(BucketUploadState.IDLE); @@ -312,7 +311,7 @@ export const useBucketUpload = ( try { // Use multipart upload for large files - if (file.size > MULTIPART_THRESHOLD) { + if (file.size > chunkSize) { const handleUploadCreated = (uploadId: string) => { setUploadId(uploadId); setState(BucketUploadState.IMPORTING); @@ -321,6 +320,7 @@ export const useBucketUpload = ( setState(BucketUploadState.INITIATING); await multiPartUploadFile( file, + chunkSize, handleUploadCreated, setXhr, handleUploadCompleting, diff --git a/src/frontend/src/features/i18n/conf.ts b/src/frontend/src/features/i18n/conf.ts index 1a70bc27..1f7352dd 100644 --- a/src/frontend/src/features/i18n/conf.ts +++ b/src/frontend/src/features/i18n/conf.ts @@ -1,23 +1,8 @@ import { APP_STORAGE_PREFIX } from "../config/constants"; -import { handle } from "../utils/errors"; -const DEFAULT_LANGUAGES = [["en-US","English"],["fr-FR","Français"],["nl-NL","Nederlands"]]; - -// TODO: Tackle async loading of languages from backend -// to avoid declaring languages in multiple places (backend and frontend) -function getLanguagesFromEnv() { - const languages = import.meta.env.NEXT_PUBLIC_LANGUAGES; - if (!languages) return DEFAULT_LANGUAGES; - try { - return JSON.parse(languages); - } catch (error) { - handle(new Error("Error parsing languages from env."), { extra: { error, languages } }); - return DEFAULT_LANGUAGES; - } -} - -export const LANGUAGES = getLanguagesFromEnv(); -export const LANGUAGES_ALLOWED = LANGUAGES.map((language: [string, string]) => language[0]); export const LANGUAGE_LOCAL_STORAGE = APP_STORAGE_PREFIX + 'language'; -export const BASE_LANGUAGE = import.meta.env.NEXT_PUBLIC_DEFAULT_LANGUAGE || LANGUAGES_ALLOWED[0]; -export const IS_LANGUAGE_FORCED = import.meta.env.NEXT_PUBLIC_FORCED_DEFAULT_LANGUAGE === 'true'; + +// Locales handled by the i18next-cli extraction (build time; the other +// `public/locales` files are managed through Crowdin). The list of languages +// enabled at runtime comes from the backend `/config` endpoint. +export const SUPPORTED_LOCALES = ["en-US", "fr-FR", "nl-NL"]; diff --git a/src/frontend/src/features/i18n/initI18n.ts b/src/frontend/src/features/i18n/initI18n.ts index ad09f7e6..3db203c4 100644 --- a/src/frontend/src/features/i18n/initI18n.ts +++ b/src/frontend/src/features/i18n/initI18n.ts @@ -2,38 +2,53 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import HttpApiBackend from "i18next-http-backend"; -import { LANGUAGES_ALLOWED, LANGUAGE_LOCAL_STORAGE } from "./conf"; +import { AppConfig } from "@/features/config/resolve"; +import { LANGUAGE_LOCAL_STORAGE } from "./conf"; import { getLanguage } from "./utils"; -i18n - .use(initReactI18next) - .use(HttpApiBackend) - .init({ - lng: getLanguage(), - supportedLngs: LANGUAGES_ALLOWED, - // Register namespaces - // - common: for the common strings - // - roles: for the roles strings as they cannot be extracted by i18next-cli as key are dynamic - // - placeholders: for the built-in template/signature placeholders (dynamic keys, same as roles) - ns: ["common", "roles", "placeholders"], - defaultNS: "common", - // Use flat keys and avoid interpreting ':' or '.' in natural language keys - keySeparator: false, - nsSeparator: false, - interpolation: { - escapeValue: false, - }, - preload: LANGUAGES_ALLOWED, - fallbackLng: 'en-US', - // Consider empty strings as missing keys to fallback to the key - returnEmptyString: false, - backend: { - loadPath: "/locales/{{ns}}/{{lng}}.json", - } - }) - .catch(() => { - throw new Error("i18n initialization failed"); - }); +/** + * Initialize i18next from the resolved application configuration. + * Called during bootstrap, before the React tree renders, as components rely + * on `useTranslation` from the very first render. Locale files load + * asynchronously; react-i18next re-renders when they arrive. + */ +export const initI18n = (config: AppConfig) => { + const languagesAllowed = config.LANGUAGES.map(([code]) => code); + + i18n + .use(initReactI18next) + .use(HttpApiBackend) + .init({ + lng: getLanguage( + languagesAllowed, + config.BASE_LANGUAGE, + config.IS_LANGUAGE_FORCED, + ), + supportedLngs: languagesAllowed, + // Register namespaces + // - common: for the common strings + // - roles: for the roles strings as they cannot be extracted by i18next-cli as key are dynamic + // - placeholders: for the built-in template/signature placeholders (dynamic keys, same as roles) + ns: ["common", "roles", "placeholders"], + defaultNS: "common", + // Use flat keys and avoid interpreting ':' or '.' in natural language keys + keySeparator: false, + nsSeparator: false, + interpolation: { + escapeValue: false, + }, + preload: languagesAllowed, + fallbackLng: [config.BASE_LANGUAGE, "en-US"], + // Consider empty strings as missing keys to fallback to the key + returnEmptyString: false, + backend: { + loadPath: "/locales/{{ns}}/{{lng}}.json", + } + }) + .catch((error) => { + throw new Error("i18n initialization failed", { cause: error }); + }); +}; // Save language in local storage i18n.on("languageChanged", (lng) => { diff --git a/src/frontend/src/features/i18n/utils.ts b/src/frontend/src/features/i18n/utils.ts index d6ca9822..491c2248 100644 --- a/src/frontend/src/features/i18n/utils.ts +++ b/src/frontend/src/features/i18n/utils.ts @@ -1,18 +1,17 @@ -import { - BASE_LANGUAGE, - IS_LANGUAGE_FORCED, - LANGUAGES_ALLOWED, - LANGUAGE_LOCAL_STORAGE, -} from './conf'; +import { LANGUAGE_LOCAL_STORAGE } from './conf'; -export const getLanguage = () => { +export const getLanguage = ( + languagesAllowed: string[], + baseLanguage: string, + isLanguageForced: boolean, +) => { if (typeof window === 'undefined') { - return BASE_LANGUAGE; + return baseLanguage; } const storedLanguage = localStorage.getItem(LANGUAGE_LOCAL_STORAGE); const languageStore = - storedLanguage || (IS_LANGUAGE_FORCED ? BASE_LANGUAGE : navigator?.language); + storedLanguage || (isLanguageForced ? baseLanguage : navigator?.language); - return LANGUAGES_ALLOWED.includes(languageStore) ? languageStore : BASE_LANGUAGE; + return languagesAllowed.includes(languageStore) ? languageStore : baseLanguage; }; diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/widget-integration-form.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/widget-integration-form.tsx index e634de47..6c6f759f 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/widget-integration-form.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/widget-integration-form.tsx @@ -14,6 +14,7 @@ import { getMailboxesChannelsListUrl, } from "@/features/api/gen"; import { RhfInput } from "@/features/forms/components/react-hook-form"; +import { useConfig } from "@/features/providers/config"; import { addToast, ToasterItem } from "@/features/ui/components/toaster"; import { Banner } from "@/features/ui/components/banner"; import { handle } from "@/features/utils/errors"; @@ -47,6 +48,7 @@ export const WidgetIntegrationForm = ({ }: WidgetIntegrationFormProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); + const { FEEDBACK_WIDGET } = useConfig(); const [error, setError] = useState(null); const widgetSettings = (channel?.settings as WidgetChannelSettings | undefined); const [selectedTags, setSelectedTags] = useState( @@ -128,15 +130,15 @@ export const WidgetIntegrationForm = ({ } }; - const widgetSnippet = channel ? ` + const widgetSnippet = channel && FEEDBACK_WIDGET.path && FEEDBACK_WIDGET.api_url ? ` ` : ""; diff --git a/src/frontend/src/features/layouts/components/main/language-picker.tsx b/src/frontend/src/features/layouts/components/main/language-picker.tsx index 1b98c332..fb3a5d51 100644 --- a/src/frontend/src/features/layouts/components/main/language-picker.tsx +++ b/src/frontend/src/features/layouts/components/main/language-picker.tsx @@ -1,15 +1,15 @@ import { LanguagePicker as BaseLanguagePicker, LanguagePickerProps } from "@gouvfr-lasuite/ui-kit"; import { useTranslation } from "react-i18next"; -import { LANGUAGES } from "@/features/i18n/conf"; +import { useConfig } from "@/features/providers/config"; import { handle } from "@/features/utils/errors"; /** - * @MARK: Those languages should be retrieved from the backend through conf API - * Furthermore, this component should be moved to the UI Kit + * @MARK: This component should be moved to the UI Kit */ export const LanguagePicker = (props: Pick) => { const { i18n } = useTranslation(); - const languages = LANGUAGES.map((language: [string, string]) => ({ + const { LANGUAGES } = useConfig(); + const languages = LANGUAGES.map((language) => ({ value: language[0], label: language[1], isChecked: i18n.language === language[0] diff --git a/src/frontend/src/features/providers/config.tsx b/src/frontend/src/features/providers/config.tsx index 21f6ca72..c5738645 100644 --- a/src/frontend/src/features/providers/config.tsx +++ b/src/frontend/src/features/providers/config.tsx @@ -1,57 +1,22 @@ -import { ConfigRetrieve200, useConfigRetrieve } from "@/features/api/gen"; +import { useConfigRetrieve } from "@/features/api/gen"; +import { AppConfig, resolveConfig } from "@/features/config/resolve"; import { Spinner } from "@gouvfr-lasuite/ui-kit"; import { PropsWithChildren, createContext, useContext, useMemo } from "react"; -type AppConfig = Omit & Required>; - -const DEFAULT_DRIVE_CONFIG: NonNullable = { - sdk_url: "", - api_url: "", - file_url: "", - preview_url: "", - app_name: "Drive", -} - -const DEFAULT_CONFIG: AppConfig = { - ENVIRONMENT: "", - LANGUAGES: [], - LANGUAGE_CODE: "", - AI_ENABLED: false, - FEATURE_AI_SUMMARY: false, - FEATURE_AI_AUTOLABELS: false, - FEATURE_MAILBOX_ADMIN_CHANNELS: [], - SCHEMA_CUSTOM_ATTRIBUTES_USER: {}, - SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN: {}, - MAX_OUTGOING_ATTACHMENT_SIZE: 0, - MAX_OUTGOING_BODY_SIZE: 0, - MAX_INCOMING_EMAIL_SIZE: 0, - MAX_RECIPIENTS_PER_MESSAGE: 0, - MAX_TEMPLATE_IMAGE_SIZE: 0, - IMAGE_PROXY_ENABLED: false, - FEATURE_MAILDOMAIN_CREATE: true, - FEATURE_MAILDOMAIN_MANAGE_ACCESSES: true, - FEATURE_MAILDOMAIN_MANAGE_TOTP: false, - FEATURE_THREAD_SPLIT: true, - DRIVE: DEFAULT_DRIVE_CONFIG, - MESSAGES_MANUAL_RETRY_MAX_AGE: 0, - FRONTEND_SILENT_LOGIN_ENABLED: false, -} - -const ConfigContext = createContext(DEFAULT_CONFIG) +const ConfigContext = createContext(undefined) /** - * A global provider in charge of fetching the config at first load - * and sharing it to the app. + * A global provider in charge of sharing the app configuration. + * The query cache is primed during bootstrap (see `bootstrap.tsx`); + * `staleTime: Infinity` keeps that primed data fresh so no second fetch + * happens on mount. When the bootstrap fetch failed, the cache is empty and + * the query retries here, letting the React tree recover a live config. */ export const ConfigProvider = ({ children }: PropsWithChildren) => { - const { data: config, isFetched } = useConfigRetrieve(); - const configValue = useMemo(() => { - if (!config) return DEFAULT_CONFIG; - return { - ...config?.data, - DRIVE: config?.data?.DRIVE ?? DEFAULT_DRIVE_CONFIG, - } - }, [config]) + const { data: config, isFetched } = useConfigRetrieve({ + query: { staleTime: Infinity }, + }); + const configValue = useMemo(() => resolveConfig(config?.data), [config]) if (!isFetched) { return ( diff --git a/src/frontend/src/features/providers/theme-favicons.ts b/src/frontend/src/features/providers/theme-favicons.ts new file mode 100644 index 00000000..634d3f83 --- /dev/null +++ b/src/frontend/src/features/providers/theme-favicons.ts @@ -0,0 +1,21 @@ +/** + * Inject theme-aware SVG favicons into . `index.html` only ships the + * fixed PWA bitmap icons, which are not theme-aware. Called during bootstrap + * so the favicon is set before the first paint. + */ +export const installThemeFavicons = (theme: string) => { + const variants: Array<{ media: string; href: string }> = [ + { media: "(prefers-color-scheme: light)", href: `/images/${theme}/favicon-light.svg` }, + { media: "(prefers-color-scheme: dark)", href: `/images/${theme}/favicon-dark.svg` }, + ]; + const links = variants.map(({ media, href }) => { + const el = document.createElement("link"); + el.rel = "icon"; + el.type = "image/svg+xml"; + el.media = media; + el.href = href; + document.head.appendChild(el); + return el; + }); + return () => links.forEach((el) => el.remove()); +}; diff --git a/src/frontend/src/features/providers/theme.tsx b/src/frontend/src/features/providers/theme.tsx index 39db26f1..96719ba6 100644 --- a/src/frontend/src/features/providers/theme.tsx +++ b/src/frontend/src/features/providers/theme.tsx @@ -1,20 +1,17 @@ import { createContext, PropsWithChildren, useContext, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { CunninghamProvider, ContextMenuProvider, FooterProps } from "@gouvfr-lasuite/ui-kit"; +import { CunninghamProvider, ContextMenuProvider } from "@gouvfr-lasuite/ui-kit"; import { THEME_KEY } from "../config/constants"; import { tokens } from '@/styles/cunningham-tokens' +import { ThemeConfig as AppThemeConfig } from "@/features/config/resolve"; +import { useConfig } from "./config"; type CunninghamTheme = keyof typeof tokens.themes; type ColorScheme = "system" | "light" | "dark"; -type Theme = "white-label" | "anct" | "dsfr"; +type Theme = AppThemeConfig["theme"]; type ThemeVariant = "light" | "dark"; type ThemeWithVariant = 'white-label-light' | 'white-label-dark' | 'anct-light' | 'anct-dark' | 'dsfr-light' | 'dsfr-dark'; -type ThemeConfigMap = { - theme: Theme; - terms_of_service_url?: string; - footer?: FooterProps; -} -type ThemeConfig = Omit; +type ThemeConfig = Omit; const ThemeContext = createContext(undefined) -const THEME_CONFIG_ENV: ThemeConfigMap = import.meta.env.NEXT_PUBLIC_THEME_CONFIG - ? JSON.parse(import.meta.env.NEXT_PUBLIC_THEME_CONFIG) as ThemeConfigMap - : { theme: "white-label" }; - -const { theme = 'white-label', ...themeConfig } = THEME_CONFIG_ENV; - const CUNNINGHAM_THEME_MAP: Record = { "white-label-light": "default", "white-label-dark": "dark", @@ -44,6 +35,8 @@ const CUNNINGHAM_THEME_MAP: Record = { const ThemeProvider = ({ children }: PropsWithChildren) => { const { i18n } = useTranslation(); + const { THEME_CONFIG } = useConfig(); + const { theme = 'white-label', ...themeConfig } = THEME_CONFIG; const defaultScheme = window.matchMedia("(prefers-color-scheme: dark)") .matches ? 'dark' diff --git a/src/frontend/src/features/sentry/index.ts b/src/frontend/src/features/sentry/index.ts new file mode 100644 index 00000000..780f0718 --- /dev/null +++ b/src/frontend/src/features/sentry/index.ts @@ -0,0 +1,19 @@ +import * as Sentry from "@sentry/react"; + +import { AppConfig } from "@/features/config/resolve"; + +/** + * Initialize Sentry from the resolved application configuration. + * Called during bootstrap, before the React tree renders, so that render + * and routing errors are captured. No-op when Sentry is not configured. + */ +export const initSentry = (config: AppConfig) => { + if (!config.SENTRY_DSN || !config.SENTRY_ENVIRONMENT) return; + + Sentry.init({ + dsn: config.SENTRY_DSN, + environment: config.SENTRY_ENVIRONMENT, + release: config.RELEASE, + }); + Sentry.setTag("application", "frontend"); +}; diff --git a/src/frontend/src/features/ui/components/feedback-button/index.tsx b/src/frontend/src/features/ui/components/feedback-button/index.tsx index de7c1479..651fa2af 100644 --- a/src/frontend/src/features/ui/components/feedback-button/index.tsx +++ b/src/frontend/src/features/ui/components/feedback-button/index.tsx @@ -2,6 +2,7 @@ import { DropdownMenu, Icon, IconType } from "@gouvfr-lasuite/ui-kit" import { Button, ButtonProps, Tooltip } from "@gouvfr-lasuite/cunningham-react" import { useTranslation } from "react-i18next" import { useAuth } from "@/features/auth"; +import { useConfig } from "@/features/providers/config"; import { useState } from "react"; import { WidgetHelper } from "@/features/utils/widget-helper"; @@ -16,12 +17,11 @@ type SurveyButtonProps = ButtonProps & { export const SurveyButton = ({ iconOnly = false, ...props }: SurveyButtonProps) => { const { t } = useTranslation() const { user } = useAuth(); + const { FEEDBACK_WIDGET, HELP_CENTER_URL } = useConfig(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const apiUrl = import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL; - const widgetPath = import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_PATH; - const channel = import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL; - const helpCenterUrl = import.meta.env.NEXT_PUBLIC_HELP_CENTER_URL; + const { api_url: apiUrl, path: widgetPath, channel } = FEEDBACK_WIDGET; + const helpCenterUrl = HELP_CENTER_URL; const hasWidget = !!(channel && apiUrl && widgetPath); const hasHelpCenter = !!helpCenterUrl; diff --git a/src/frontend/src/features/ui/components/feedback-widget/index.tsx b/src/frontend/src/features/ui/components/feedback-widget/index.tsx index 0c239869..3aec2eed 100644 --- a/src/frontend/src/features/ui/components/feedback-widget/index.tsx +++ b/src/frontend/src/features/ui/components/feedback-widget/index.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useAuth } from "@/features/auth"; +import { useConfig } from "@/features/providers/config"; import { WidgetHelper } from "@/features/utils/widget-helper"; interface FeedbackWidgetProps { @@ -12,12 +13,10 @@ export function FeedbackWidget({ }: FeedbackWidgetProps) { const { t } = useTranslation(); const { user } = useAuth(); + const { FEEDBACK_WIDGET } = useConfig(); - const apiUrl = import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL; - const widgetPath = import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_PATH; - const channel = - import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_HOME_CHANNEL || - import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL; + const { api_url: apiUrl, path: widgetPath } = FEEDBACK_WIDGET; + const channel = FEEDBACK_WIDGET.home_channel || FEEDBACK_WIDGET.channel; const title: string = t("Do you have any feedback?"); const placeholder: string = t("Share your feedback here..."); diff --git a/src/frontend/src/features/ui/components/lagaufre/index.tsx b/src/frontend/src/features/ui/components/lagaufre/index.tsx index 15143496..f22677a7 100644 --- a/src/frontend/src/features/ui/components/lagaufre/index.tsx +++ b/src/frontend/src/features/ui/components/lagaufre/index.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next" import { useEffect, useRef } from "react" import { LaGaufreV2 } from "@gouvfr-lasuite/ui-kit"; +import { useConfig } from "@/features/providers/config"; const LAGAUFRE_SHADOW_HOST_ID = "lasuite-widget-lagaufre-shadow"; @@ -9,9 +10,9 @@ const LAGAUFRE_SHADOW_HOST_ID = "lasuite-widget-lagaufre-shadow"; */ export const LagaufreButton = () => { const { t } = useTranslation() + const { LAGAUFRE_WIDGET } = useConfig(); const wrapperRef = useRef(null); - const apiUrl = import.meta.env.NEXT_PUBLIC_LAGAUFRE_WIDGET_API_URL; - const widgetPath = import.meta.env.NEXT_PUBLIC_LAGAUFRE_WIDGET_PATH; + const { api_url: apiUrl, path: widgetPath } = LAGAUFRE_WIDGET; const isEnabled = apiUrl && widgetPath; // TODO: temporary workaround — remove once fixed upstream in the lagaufre diff --git a/src/frontend/src/features/utils/errors/index.ts b/src/frontend/src/features/utils/errors/index.ts index ef763e1f..dad8e7c4 100644 --- a/src/frontend/src/features/utils/errors/index.ts +++ b/src/frontend/src/features/utils/errors/index.ts @@ -1,7 +1,5 @@ import * as Sentry from "@sentry/react"; -const isSentryEnabled = import.meta.env.NEXT_PUBLIC_SENTRY_DSN && import.meta.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT; - type CaptureExceptionContext = Parameters[1]; /** @@ -9,7 +7,7 @@ type CaptureExceptionContext = Parameters[1]; * Passes errors to Sentry if available, logs the error to the console otherwise. */ export const handle = (error: unknown, context?: CaptureExceptionContext) => { - if (isSentryEnabled) { + if (Sentry.isInitialized()) { Sentry.captureException(error, context); } else { console.error(error, context); diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx index c57869f4..6b98ff26 100644 --- a/src/frontend/src/main.tsx +++ b/src/frontend/src/main.tsx @@ -1,32 +1,6 @@ import "@blocknote/mantine/style.css"; import "./styles/main.scss"; -import "./features/i18n/initI18n"; -import "../instrumentation-client"; -import { createRoot } from "react-dom/client"; -import { createRouter, parseSearchWith, RouterProvider, stringifySearchWith } from "@tanstack/react-router"; +import { bootstrap } from "./bootstrap"; -import { routeTree } from "./routes.gen"; - -// Default TSR encoding JSON-wraps every search value (`?key=1` → `?key=%221%22`). -// The rest of the app builds URLs via `URLSearchParams.toString()` and the -// backend expects plain values, so we plug identity parsers to keep both sides -// aligned — values stay as raw strings on the way out and on the way back. -const router = createRouter({ - routeTree, - scrollRestoration: false, - defaultPreload: false, - parseSearch: parseSearchWith((value) => value), - stringifySearch: stringifySearchWith((value) => (value == null ? "" : String(value))), -}); - -declare module "@tanstack/react-router" { - interface Register { - router: typeof router; - } -} - -const container = document.getElementById("root"); -if (!container) throw new Error("#root element not found in index.html"); - -createRoot(container).render(); +void bootstrap(); diff --git a/src/frontend/src/routes/__root.tsx b/src/frontend/src/routes/__root.tsx index da07ad16..33dc65be 100644 --- a/src/frontend/src/routes/__root.tsx +++ b/src/frontend/src/routes/__root.tsx @@ -1,82 +1,16 @@ import { createRootRoute, Outlet } from "@tanstack/react-router"; import { useEffect } from "react"; -import { - MutationCache, - Query, - QueryCache, - QueryClient, - QueryClientProvider, -} from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { useTranslation } from "react-i18next"; -import { addToast, ToasterItem } from "@/features/ui/components/toaster"; -import { errorToString } from "@/features/api/api-error"; +import { queryClient } from "@/features/api/query-client"; import { Auth } from "@/features/auth"; import { ConfigProvider } from "@/features/providers/config"; import ErrorBoundary from "@/features/errors/error-boundary"; import ThemeProvider from "@/features/providers/theme"; -const onError = (error: Error, query: unknown) => { - if ((query as Query).meta?.noGlobalError) { - return; - } - addToast( - - {errorToString(error)} - , - { - toastId: "APPLICATION_ERROR_TOAST", - }, - ); -}; - -const queryClient = new QueryClient({ - mutationCache: new MutationCache({ - onError: (error, _variables, _context, mutation) => onError(error, mutation), - }), - queryCache: new QueryCache({ - onError: (error, query) => onError(error, query), - }), - defaultOptions: { - queries: { - retry: false, - refetchOnWindowFocus: false, - }, - }, -}); - -const DEFAULT_THEME = "white-label"; -const parseTheme = (raw: string | undefined): string => { - if (!raw) return DEFAULT_THEME; - try { - return JSON.parse(raw)?.theme ?? DEFAULT_THEME; - } catch { - return DEFAULT_THEME; - } -}; -const THEME = parseTheme(import.meta.env.NEXT_PUBLIC_THEME_CONFIG); - -// Inject theme-aware SVG favicons into . `index.html` only ships the -// fixed PWA bitmap icons, which are not theme-aware. -const installThemeFavicons = (theme: string) => { - const variants: Array<{ media: string; href: string }> = [ - { media: "(prefers-color-scheme: light)", href: `/images/${theme}/favicon-light.svg` }, - { media: "(prefers-color-scheme: dark)", href: `/images/${theme}/favicon-dark.svg` }, - ]; - const links = variants.map(({ media, href }) => { - const el = document.createElement("link"); - el.rel = "icon"; - el.type = "image/svg+xml"; - el.media = media; - el.href = href; - document.head.appendChild(el); - return el; - }); - return () => links.forEach((el) => el.remove()); -}; - const RootShell = () => { const { t } = useTranslation(); @@ -84,8 +18,6 @@ const RootShell = () => { document.title = t("Messaging"); }, [t]); - useEffect(() => installThemeFavicons(THEME), []); - return ( diff --git a/src/frontend/src/vite-env.d.ts b/src/frontend/src/vite-env.d.ts index 8febfffd..13fafdc6 100644 --- a/src/frontend/src/vite-env.d.ts +++ b/src/frontend/src/vite-env.d.ts @@ -2,19 +2,33 @@ interface ImportMetaEnv { readonly NEXT_PUBLIC_API_ORIGIN?: string; + /** @deprecated use the FRONTEND_THEME_CONFIG backend setting */ readonly NEXT_PUBLIC_THEME_CONFIG?: string; + /** @deprecated use the LANGUAGES backend setting */ readonly NEXT_PUBLIC_LANGUAGES?: string; + /** @deprecated use the LANGUAGE_CODE backend setting */ readonly NEXT_PUBLIC_DEFAULT_LANGUAGE?: string; + /** @deprecated use the FRONTEND_FORCED_DEFAULT_LANGUAGE backend setting */ readonly NEXT_PUBLIC_FORCED_DEFAULT_LANGUAGE?: string; + /** @deprecated use the FRONTEND_FEEDBACK_WIDGET_CONFIG backend setting */ readonly NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL?: string; + /** @deprecated use the FRONTEND_FEEDBACK_WIDGET_CONFIG backend setting */ readonly NEXT_PUBLIC_FEEDBACK_WIDGET_PATH?: string; + /** @deprecated use the FRONTEND_FEEDBACK_WIDGET_CONFIG backend setting */ readonly NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL?: string; + /** @deprecated use the FRONTEND_FEEDBACK_WIDGET_CONFIG backend setting */ readonly NEXT_PUBLIC_FEEDBACK_WIDGET_HOME_CHANNEL?: string; + /** @deprecated use the FRONTEND_HELP_CENTER_URL backend setting */ readonly NEXT_PUBLIC_HELP_CENTER_URL?: string; + /** @deprecated use the FRONTEND_LAGAUFRE_WIDGET_CONFIG backend setting */ readonly NEXT_PUBLIC_LAGAUFRE_WIDGET_API_URL?: string; + /** @deprecated use the FRONTEND_LAGAUFRE_WIDGET_CONFIG backend setting */ readonly NEXT_PUBLIC_LAGAUFRE_WIDGET_PATH?: string; + /** @deprecated use the FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB backend setting */ readonly NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE?: string; + /** @deprecated use the SENTRY_DSN backend setting */ readonly NEXT_PUBLIC_SENTRY_DSN?: string; + /** @deprecated the frontend now uses the backend ENVIRONMENT */ readonly NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string; } diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index 30dfd306..484f20bb 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -33,8 +33,10 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, - // App env vars are read via `import.meta.env.NEXT_PUBLIC_*`. envPrefix - // tells Vite which env vars to expose to client code at build time. + // Runtime configuration comes from the backend /config endpoint; the only + // build-time env vars left are NEXT_PUBLIC_API_ORIGIN and the deprecated + // NEXT_PUBLIC_* fallbacks (see features/config/resolve.ts). envPrefix tells + // Vite which env vars to expose to client code at build time. envPrefix: 'NEXT_PUBLIC_', build: { outDir: 'dist', diff --git a/src/frontend/vitest.config.ts b/src/frontend/vitest.config.ts index b947b5bc..f7a15752 100644 --- a/src/frontend/vitest.config.ts +++ b/src/frontend/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', - setupFiles: [], + setupFiles: ['./vitest.setup.ts'], include: ['**/*.test.{ts,tsx}'], coverage: { provider: 'v8', @@ -18,7 +18,7 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, - // Keep parity with vite.config.ts so tests see NEXT_PUBLIC_* via - // import.meta.env. + // Keep parity with vite.config.ts so tests can stub the NEXT_PUBLIC_* + // deprecated fallbacks via import.meta.env. envPrefix: 'NEXT_PUBLIC_', }); \ No newline at end of file diff --git a/src/frontend/vitest.setup.ts b/src/frontend/vitest.setup.ts new file mode 100644 index 00000000..0f15b308 --- /dev/null +++ b/src/frontend/vitest.setup.ts @@ -0,0 +1,6 @@ +import { resolveConfig } from "@/features/config/resolve"; +import { initI18n } from "@/features/i18n/initI18n"; + +// i18n used to be initialized as an import side effect; it is now explicitly +// initialized during bootstrap. Tests get the same default-config setup here. +initI18n(resolveConfig());