🔧(global) set frontend configuration from backend (#734)

Pass frontend configuration through backend to prevent be able to edit it
without requiring frontend application.
This commit is contained in:
Jean-Baptiste PENRATH
2026-07-07 15:13:00 +02:00
committed by GitHub
parent ce2b8f6ab0
commit 983df0fe6f
41 changed files with 1293 additions and 552 deletions
+34 -13
View File
@@ -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
+6 -1
View File
@@ -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
+2 -19
View File
@@ -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"
}'
+75 -25
View File
@@ -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",
+223 -201
View File
@@ -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
}
)
+1 -3
View File
@@ -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),
+52 -3
View File
@@ -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,
+21
View File
@@ -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
+35 -7
View File
@@ -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:
+2 -2
View File
@@ -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}'],
-11
View File
@@ -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");
}
+67
View File
@@ -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(<RouterProvider router={router} />);
} catch (error) {
// Last-resort safety net: this runs before the React ErrorBoundary exists.
handle(error);
container.innerHTML =
"<p>Something went wrong while starting the application. Please try again later.</p>";
}
};
@@ -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;
};
@@ -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;
};
@@ -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;
};
@@ -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 };
@@ -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";
@@ -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(
<ToasterItem type="error">
<span>{errorToString(error)}</span>
</ToasterItem>,
{
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,
},
},
});
+2 -7
View File
@@ -1,7 +1,2 @@
export const getConfig = () => {
// TODO: Later, be based on URL query params for instance.
return {
};
};
export * from "./constants";
export * from "./resolve";
@@ -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");
});
});
+290
View File
@@ -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<ConfigRetrieve200["DRIVE"]>;
/**
* 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<string>();
/**
* 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 = <T>(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<ThemeConfig>(
"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),
};
};
@@ -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<PartUpload>(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<MultipartPartResponse>(
@@ -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<File | null>(null);
const uploadIdRef = useRef<string | null>(null);
const [state, setState] = useState<BucketUploadState>(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,
+5 -20
View File
@@ -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"];
+45 -30
View File
@@ -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) => {
+9 -10
View File
@@ -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;
};
@@ -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<string | null>(null);
const widgetSettings = (channel?.settings as WidgetChannelSettings | undefined);
const [selectedTags, setSelectedTags] = useState<string[]>(
@@ -128,15 +130,15 @@ export const WidgetIntegrationForm = ({
}
};
const widgetSnippet = channel ? `<script src="${import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_PATH}loader.js" async></script>
const widgetSnippet = channel && FEEDBACK_WIDGET.path && FEEDBACK_WIDGET.api_url ? `<script src="${FEEDBACK_WIDGET.path}loader.js" async></script>
<script>
window._lasuite_widget = window._lasuite_widget || [];
_lasuite_widget.push(["loader", "init", {
"params": {
"api": "${import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL}",
"api": "${FEEDBACK_WIDGET.api_url}",
"channel": "${channel.id}"
},
"script": "${import.meta.env.NEXT_PUBLIC_FEEDBACK_WIDGET_PATH}feedback.js",
"script": "${FEEDBACK_WIDGET.path}feedback.js",
"widget": "feedback",
}]);
</script>` : "";
@@ -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<LanguagePickerProps, "size" | "color" | "variant" | "fullWidth" | "compact">) => {
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]
+12 -47
View File
@@ -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<ConfigRetrieve200, 'DRIVE'> & Required<Pick<ConfigRetrieve200, 'DRIVE'>>;
const DEFAULT_DRIVE_CONFIG: NonNullable<ConfigRetrieve200['DRIVE']> = {
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<AppConfig>(DEFAULT_CONFIG)
const ConfigContext = createContext<AppConfig | undefined>(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 (
@@ -0,0 +1,21 @@
/**
* Inject theme-aware SVG favicons into <head>. `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());
};
+7 -14
View File
@@ -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<ThemeConfigMap, "theme">;
type ThemeConfig = Omit<AppThemeConfig, "theme">;
const ThemeContext = createContext<undefined | {
colorScheme: ColorScheme;
@@ -26,12 +23,6 @@ const ThemeContext = createContext<undefined | {
cunninghamTheme: CunninghamTheme;
}>(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<ThemeWithVariant, CunninghamTheme> = {
"white-label-light": "default",
"white-label-dark": "dark",
@@ -44,6 +35,8 @@ const CUNNINGHAM_THEME_MAP: Record<ThemeWithVariant, CunninghamTheme> = {
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'
+19
View File
@@ -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");
};
@@ -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;
@@ -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...");
@@ -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<HTMLDivElement>(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
@@ -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<typeof Sentry.captureException>[1];
/**
@@ -9,7 +7,7 @@ type CaptureExceptionContext = Parameters<typeof Sentry.captureException>[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);
+2 -28
View File
@@ -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(<RouterProvider router={router} />);
void bootstrap();
+2 -70
View File
@@ -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(
<ToasterItem type="error">
<span>{errorToString(error)}</span>
</ToasterItem>,
{
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 <head>. `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 (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
+14
View File
@@ -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;
}
+4 -2
View File
@@ -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',
+3 -3
View File
@@ -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_',
});
+6
View File
@@ -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());