mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
🔧(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:
+34
-13
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}'
|
||||
|
||||
|
||||
@@ -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": "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",
|
||||
|
||||
@@ -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(
|
||||
return drf.response.Response(
|
||||
{
|
||||
"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"),
|
||||
}
|
||||
entry.key: value
|
||||
for entry in CONFIG_ENTRIES
|
||||
if (value := entry.resolve()) is not None or entry.required
|
||||
}
|
||||
)
|
||||
|
||||
return drf.response.Response(dict_settings)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}'],
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+17
@@ -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;
|
||||
};
|
||||
+15
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
+10
-10
@@ -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,
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -2,15 +2,29 @@ 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";
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
supportedLngs: LANGUAGES_ALLOWED,
|
||||
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
|
||||
@@ -23,17 +37,18 @@ i18n
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
preload: LANGUAGES_ALLOWED,
|
||||
fallbackLng: 'en-US',
|
||||
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(() => {
|
||||
throw new Error("i18n initialization failed");
|
||||
.catch((error) => {
|
||||
throw new Error("i18n initialization failed", { cause: error });
|
||||
});
|
||||
};
|
||||
|
||||
// Save language in local storage
|
||||
i18n.on("languageChanged", (lng) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+5
-3
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Vendored
+14
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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_',
|
||||
});
|
||||
@@ -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());
|
||||
Reference in New Issue
Block a user