diff --git a/ENVIRONMENT_VARIABLES.md b/ENVIRONMENT_VARIABLES.md index 71f8dd1a..5b240084 100644 --- a/ENVIRONMENT_VARIABLES.md +++ b/ENVIRONMENT_VARIABLES.md @@ -12,7 +12,7 @@ For a working starting point, see [`server/.env.sample`](./server/.env.sample) ( | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BASE_URL` | Public URL of the application (e.g. `https://projects.example.com`). Used to build absolute links (emails, webhooks, OIDC redirect/logout URIs) and to derive the base path/protocol. | | `DATABASE_URL` | PostgreSQL connection string (e.g. `postgresql://user:password@host:5432/db`). | -| `SECRET_KEY` | Secret used to sign session cookies. Use a long, random value in production. | +| `SECRET_KEY` | Secret used to sign session cookies. Use a long, random value in production. When running multiple replicas, they must all share the **same** value (see [Horizontal scaling](#horizontal-scaling-multiple-instances-optional)). | ### Core / general @@ -63,6 +63,18 @@ Only needed to store attachments/avatars/backgrounds on an S3-compatible bucket | `S3_BUCKET` | Bucket name. | | `S3_FORCE_PATH_STYLE` | Set to `true` for endpoints that require path-style requests (e.g. MinIO). | +### Horizontal scaling (multiple instances), optional + +Only needed when running **more than one** instance/process behind a load balancer. By default the app keeps sessions in memory and broadcasts realtime socket events per-process, so a second instance would not receive the live updates emitted by the first. Setting `REDIS_URL` shares both sessions and socket.io broadcasts through Redis (via `@sailshq/connect-redis` and `@sailshq/socket.io-redis`), which is required for the realtime collaboration to work across instances. Only applied in production (`NODE_ENV=production`). + +All replicas must sit behind a load balancer that forwards to each instance's app port (`1337`) and share the **same `SECRET_KEY`**: session ID cookies are signed with it, so a cookie issued by one instance is only accepted by the others when the secret matches. They must likewise share the same `DATABASE_URL` and `REDIS_URL`. (How the balancer reaches that port — a published host port vs. the container directly — depends on your deployment; see [`docker-compose.yml`](./docker-compose.yml) for the Docker topology.) + +To scale out you must **also** move file storage off local disk to [S3](#object-storage-s3-optional) — otherwise attachments/avatars/backgrounds uploaded on one instance are not visible from the others. + +| Variable | Default | Description | +| ----------- | ------- | ----------------------------------------------------------------------------------------------------- | +| `REDIS_URL` | unset | Redis connection string (e.g. `redis://user:password@host:6379/0`). Enables shared sessions + sockets. | + ### OpenID Connect (OIDC) | Variable | Default | Description | diff --git a/client/patches/@gouvfr-lasuite+ui-kit+0.19.10.patch b/client/patches/@gouvfr-lasuite+ui-kit+0.19.10.patch new file mode 100644 index 00000000..c381dd71 --- /dev/null +++ b/client/patches/@gouvfr-lasuite+ui-kit+0.19.10.patch @@ -0,0 +1,27 @@ +diff --git a/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js b/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js +index f13985b..bcd7389 100644 +--- a/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js ++++ b/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js +@@ -287,9 +287,9 @@ function k1() { + return Va || (Va = 1, process.env.NODE_ENV === "production" ? mn.exports = A1() : mn.exports = T1()), mn.exports; + } + var g = k1(); +-const I1 = { share: { copyLink: "Copy link", ok: "OK", shareButton: "Share", modalTitle: "Share folder", modalAriaLabel: "Share modal", access: { delete: "Remove access" }, cannot_view: { message: "You can view this item but you need additional access to view its members or modify the settings." }, invitations: { title: "Pending invitations" }, members: { title_plural: "Shared between {count} people", title_singular: "Shared between {count} person", load_more: "Show more " }, item: { add: "Add" }, search: { placeholder: "Search user", group_name: "Search user result" }, user: { no_result: "No result", placeholder: "Search for a user to invite" }, linkSettings: { title: "Link settings", reach: { choices: { public: { title: "Public", description: "Anyone with the link can access the document" }, authenticated: { title: "Authenticated", description: "Only authenticated users can access the document" }, restricted: { title: "Private", description: "Only users of the space can access the document" } } }, role: { choices: { reader: { title: "Reader" }, editor: { title: "Editor" } } } } }, laGaufre: { label: "Digital LaSuite services", closeLabel: "Close the menu", viewMoreLabel: "View more", viewLessLabel: "View less", loadingText: "Loading…", newWindowLabelSuffix: " (new window)", headerLabel: "About" }, userMenu: { term_of_service: "Terms of service", manage_account: "Manage account", open: "Open user menu", close: "Close user menu", accountSettings: "Account settings", logout: "Logout", dialogTitle: "User menu" }, treeView: { viewMore: "Load more elements" }, footer: { logo: { alt: "Logo of the French government" } }, onboarding: { skip: "Skip", next: "Next", previous: "Previous", complete: "Understood", stepLabel: "Step {current} of {total}: {title}", currentStepSuffix: " (current)", contentRegionLabel: "Step {current} of {total} content" } }, O1 = { ++const I1 = { share: { copyLink: "Copy link", ok: "OK", shareButton: "Share", modalTitle: "Share folder", modalAriaLabel: "Share modal", access: { delete: "Remove access" }, cannot_view: { message: "You can view this item but you need additional access to view its members or modify the settings." }, invitations: { title: "Pending invitations" }, members: { title_plural: "Shared between {count} people", title_singular: "Shared between {count} person", load_more: "Show more " }, item: { add: "Add" }, search: { placeholder: "Search user", group_name: "Search user result" }, user: { no_result: "No result, please use the whole email address to expand the search to all platform users", placeholder: "Search for a user to invite" }, linkSettings: { title: "Link settings", reach: { choices: { public: { title: "Public", description: "Anyone with the link can access the document" }, authenticated: { title: "Authenticated", description: "Only authenticated users can access the document" }, restricted: { title: "Private", description: "Only users of the space can access the document" } } }, role: { choices: { reader: { title: "Reader" }, editor: { title: "Editor" } } } } }, laGaufre: { label: "Digital LaSuite services", closeLabel: "Close the menu", viewMoreLabel: "View more", viewLessLabel: "View less", loadingText: "Loading…", newWindowLabelSuffix: " (new window)", headerLabel: "About" }, userMenu: { term_of_service: "Terms of service", manage_account: "Manage account", open: "Open user menu", close: "Close user menu", accountSettings: "Account settings", logout: "Logout", dialogTitle: "User menu" }, treeView: { viewMore: "Load more elements" }, footer: { logo: { alt: "Logo of the French government" } }, onboarding: { skip: "Skip", next: "Next", previous: "Previous", complete: "Understood", stepLabel: "Step {current} of {total}: {title}", currentStepSuffix: " (current)", contentRegionLabel: "Step {current} of {total} content" } }, O1 = { + components: I1 +-}, N1 = { share: { copyLink: "Copier le lien", ok: "OK", modalTitle: "Partage", modalAriaLabel: "Modale de partage", shareButton: "Partager", access: { delete: "Retirer l'accès" }, cannot_view: { message: "Vous pouvez voir cet élément mais vous avez besoin d'un accès supplémentaire pour voir ses membres ou modifier les paramètres." }, invitations: { title: "Invitations en attente" }, members: { title_plural: "Partagé entre {count} personnes", title_singular: "Partagé entre {count} personne", load_more: "Afficher plus" }, item: { add: "Ajouter" }, search: { placeholder: "Rechercher un utilisateur", group_name: "Résultat de la recherche d'utilisateur" }, user: { no_result: "Aucun résultat", placeholder: "Rechercher un utilisateur à inviter" }, linkSettings: { title: "Paramètres du lien", reach: { choices: { public: { title: "Public", description: "N'importe qui avec le lien peut accéder au document" }, restricted: { title: "Privé", description: "Seules les personnes avec accès peuvent l'ouvrir à l'aide du lien" }, authenticated: { title: "Authenticated", description: "Seuls les utilisateurs authentifiés peuvent accéder au document" } } }, role: { choices: { reader: { title: "Lecture seule" }, editor: { title: "Édition" } } } } }, laGaufre: { label: "Services de la Suite numérique", closeLabel: "Fermer le menu", loadingText: "Chargement…", viewMoreLabel: "Voir plus", viewLessLabel: "Voir moins", newWindowLabelSuffix: " (nouvelle fenêtre)", headerLabel: "À propos" }, userMenu: { term_of_service: "Conditions d'utilisation", open: "Ouvrir le menu utilisateur", close: "Fermer le menu utilisateur", accountSettings: "Paramètres du compte", logout: "Déconnexion", dialogTitle: "Menu utilisateur", manage_account: "Gérer le compte" }, treeView: { viewMore: "Charger plus d'éléments" }, footer: { logo: { alt: "Logo du gouvernement français" } }, onboarding: { skip: "Passer", next: "Suivant", previous: "Précédent", complete: "Compris", stepLabel: "Étape {current} sur {total} : {title}", currentStepSuffix: " (actuelle)", contentRegionLabel: "Contenu de l'étape {current} sur {total}" } }, B1 = { ++}, N1 = { share: { copyLink: "Copier le lien", ok: "OK", modalTitle: "Partage", modalAriaLabel: "Modale de partage", shareButton: "Partager", access: { delete: "Retirer l'accès" }, cannot_view: { message: "Vous pouvez voir cet élément mais vous avez besoin d'un accès supplémentaire pour voir ses membres ou modifier les paramètres." }, invitations: { title: "Invitations en attente" }, members: { title_plural: "Partagé entre {count} personnes", title_singular: "Partagé entre {count} personne", load_more: "Afficher plus" }, item: { add: "Ajouter" }, search: { placeholder: "Rechercher un utilisateur", group_name: "Résultat de la recherche d'utilisateur" }, user: { no_result: "Aucun résultat, veuillez utiliser l'adresse email complète pour élargir la recherche à tous les utilisateurs de la plateforme", placeholder: "Rechercher un utilisateur à inviter" }, linkSettings: { title: "Paramètres du lien", reach: { choices: { public: { title: "Public", description: "N'importe qui avec le lien peut accéder au document" }, restricted: { title: "Privé", description: "Seules les personnes avec accès peuvent l'ouvrir à l'aide du lien" }, authenticated: { title: "Authenticated", description: "Seuls les utilisateurs authentifiés peuvent accéder au document" } } }, role: { choices: { reader: { title: "Lecture seule" }, editor: { title: "Édition" } } } } }, laGaufre: { label: "Services de la Suite numérique", closeLabel: "Fermer le menu", loadingText: "Chargement…", viewMoreLabel: "Voir plus", viewLessLabel: "Voir moins", newWindowLabelSuffix: " (nouvelle fenêtre)", headerLabel: "À propos" }, userMenu: { term_of_service: "Conditions d'utilisation", open: "Ouvrir le menu utilisateur", close: "Fermer le menu utilisateur", accountSettings: "Paramètres du compte", logout: "Déconnexion", dialogTitle: "Menu utilisateur", manage_account: "Gérer le compte" }, treeView: { viewMore: "Charger plus d'éléments" }, footer: { logo: { alt: "Logo du gouvernement français" } }, onboarding: { skip: "Passer", next: "Suivant", previous: "Précédent", complete: "Compris", stepLabel: "Étape {current} sur {total} : {title}", currentStepSuffix: " (actuelle)", contentRegionLabel: "Contenu de l'étape {current} sur {total}" } }, B1 = { + components: N1 + }; + function Qi(t) { +@@ -21568,9 +21568,7 @@ const La = ({ + (X) => !I.includes(X) + ); + let V = m(E !== "" ? "components.share.user.no_result" : "components.share.user.placeholder"); +- const Q = ((X) => !!X.match( +- /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z\-0-9]{2,}))$/ +- ))(E ?? "") && !(R != null && R.some((X) => X.email === E)), te = { ++ const Q = false, te = { + id: E, + full_name: "", + email: E diff --git a/client/patches/@gouvfr-lasuite+ui-kit+0.19.8.patch b/client/patches/@gouvfr-lasuite+ui-kit+0.19.8.patch deleted file mode 100644 index 7265439b..00000000 --- a/client/patches/@gouvfr-lasuite+ui-kit+0.19.8.patch +++ /dev/null @@ -1,27 +0,0 @@ -diff --git a/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js b/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js -index ab2c51e..efa072b 100644 ---- a/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js -+++ b/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js -@@ -287,9 +287,9 @@ function k1() { - return Va || (Va = 1, process.env.NODE_ENV === "production" ? mn.exports = A1() : mn.exports = T1()), mn.exports; - } - var g = k1(); --const I1 = { share: { copyLink: "Copy link", ok: "OK", shareButton: "Share", modalTitle: "Share folder", modalAriaLabel: "Share modal", access: { delete: "Remove access" }, cannot_view: { message: "You can view this item but you need additional access to view its members or modify the settings." }, invitations: { title: "Pending invitations" }, members: { title_plural: "Shared between {count} people", title_singular: "Shared between {count} person", load_more: "Show more " }, item: { add: "Add" }, search: { placeholder: "Search user", group_name: "Search user result" }, user: { no_result: "No result", placeholder: "Search user" }, linkSettings: { title: "Link settings", reach: { choices: { public: { title: "Public", description: "Anyone with the link can access the document" }, authenticated: { title: "Authenticated", description: "Only authenticated users can access the document" }, restricted: { title: "Private", description: "Only users of the space can access the document" } } }, role: { choices: { reader: { title: "Reader" }, editor: { title: "Editor" } } } } }, laGaufre: { label: "Digital LaSuite services", closeLabel: "Close the menu", viewMoreLabel: "View more", viewLessLabel: "View less", loadingText: "Loading…", newWindowLabelSuffix: " (new window)", headerLabel: "About" }, userMenu: { term_of_service: "Terms of service", manage_account: "Manage account", open: "Open user menu", close: "Close user menu", accountSettings: "Account settings", logout: "Logout", dialogTitle: "User menu" }, treeView: { viewMore: "Load more elements" }, footer: { logo: { alt: "Logo of the French government" } }, onboarding: { skip: "Skip", next: "Next", previous: "Previous", complete: "Understood", stepLabel: "Step {current} of {total}: {title}", currentStepSuffix: " (current)", contentRegionLabel: "Step {current} of {total} content" } }, O1 = { -+const I1 = { share: { copyLink: "Copy link", ok: "OK", shareButton: "Share", modalTitle: "Share folder", modalAriaLabel: "Share modal", access: { delete: "Remove access" }, cannot_view: { message: "You can view this item but you need additional access to view its members or modify the settings." }, invitations: { title: "Pending invitations" }, members: { title_plural: "Shared between {count} people", title_singular: "Shared between {count} person", load_more: "Show more " }, item: { add: "Add" }, search: { placeholder: "Search user", group_name: "Search user result" }, user: { no_result: "No result, please use the whole email address to expand the search to all platform users", placeholder: "Search user" }, linkSettings: { title: "Link settings", reach: { choices: { public: { title: "Public", description: "Anyone with the link can access the document" }, authenticated: { title: "Authenticated", description: "Only authenticated users can access the document" }, restricted: { title: "Private", description: "Only users of the space can access the document" } } }, role: { choices: { reader: { title: "Reader" }, editor: { title: "Editor" } } } } }, laGaufre: { label: "Digital LaSuite services", closeLabel: "Close the menu", viewMoreLabel: "View more", viewLessLabel: "View less", loadingText: "Loading…", newWindowLabelSuffix: " (new window)", headerLabel: "About" }, userMenu: { term_of_service: "Terms of service", manage_account: "Manage account", open: "Open user menu", close: "Close user menu", accountSettings: "Account settings", logout: "Logout", dialogTitle: "User menu" }, treeView: { viewMore: "Load more elements" }, footer: { logo: { alt: "Logo of the French government" } }, onboarding: { skip: "Skip", next: "Next", previous: "Previous", complete: "Understood", stepLabel: "Step {current} of {total}: {title}", currentStepSuffix: " (current)", contentRegionLabel: "Step {current} of {total} content" } }, O1 = { - components: I1 --}, N1 = { share: { copyLink: "Copier le lien", ok: "OK", modalTitle: "Partage", modalAriaLabel: "Modale de partage", shareButton: "Partager", access: { delete: "Retirer l'accès" }, cannot_view: { message: "Vous pouvez voir cet élément mais vous avez besoin d'un accès supplémentaire pour voir ses membres ou modifier les paramètres." }, invitations: { title: "Invitations en attente" }, members: { title_plural: "Partagé entre {count} personnes", title_singular: "Partagé entre {count} personne", load_more: "Afficher plus" }, item: { add: "Ajouter" }, search: { placeholder: "Rechercher un utilisateur", group_name: "Résultat de la recherche d'utilisateur" }, user: { no_result: "Aucun résultat", placeholder: "Rechercher un utilisateur" }, linkSettings: { title: "Paramètres du lien", reach: { choices: { public: { title: "Public", description: "N'importe qui avec le lien peut accéder au document" }, restricted: { title: "Privé", description: "Seules les personnes avec accès peuvent l'ouvrir à l'aide du lien" }, authenticated: { title: "Authenticated", description: "Seuls les utilisateurs authentifiés peuvent accéder au document" } } }, role: { choices: { reader: { title: "Lecture seule" }, editor: { title: "Édition" } } } } }, laGaufre: { label: "Services de la Suite numérique", closeLabel: "Fermer le menu", loadingText: "Chargement…", viewMoreLabel: "Voir plus", viewLessLabel: "Voir moins", newWindowLabelSuffix: " (nouvelle fenêtre)", headerLabel: "À propos" }, userMenu: { term_of_service: "Conditions d'utilisation", open: "Ouvrir le menu utilisateur", close: "Fermer le menu utilisateur", accountSettings: "Paramètres du compte", logout: "Déconnexion", dialogTitle: "Menu utilisateur", manage_account: "Gérer le compte" }, treeView: { viewMore: "Charger plus d'éléments" }, footer: { logo: { alt: "Logo du gouvernement français" } }, onboarding: { skip: "Passer", next: "Suivant", previous: "Précédent", complete: "Compris", stepLabel: "Étape {current} sur {total} : {title}", currentStepSuffix: " (actuelle)", contentRegionLabel: "Contenu de l'étape {current} sur {total}" } }, B1 = { -+}, N1 = { share: { copyLink: "Copier le lien", ok: "OK", modalTitle: "Partage", modalAriaLabel: "Modale de partage", shareButton: "Partager", access: { delete: "Retirer l'accès" }, cannot_view: { message: "Vous pouvez voir cet élément mais vous avez besoin d'un accès supplémentaire pour voir ses membres ou modifier les paramètres." }, invitations: { title: "Invitations en attente" }, members: { title_plural: "Partagé entre {count} personnes", title_singular: "Partagé entre {count} personne", load_more: "Afficher plus" }, item: { add: "Ajouter" }, search: { placeholder: "Rechercher un utilisateur", group_name: "Résultat de la recherche d'utilisateur" }, user: { no_result: "Aucun résultat, veuillez utiliser l'adresse email complète pour élargir la recherche à tous les utilisateurs de la plateforme", placeholder: "Rechercher un utilisateur" }, linkSettings: { title: "Paramètres du lien", reach: { choices: { public: { title: "Public", description: "N'importe qui avec le lien peut accéder au document" }, restricted: { title: "Privé", description: "Seules les personnes avec accès peuvent l'ouvrir à l'aide du lien" }, authenticated: { title: "Authenticated", description: "Seuls les utilisateurs authentifiés peuvent accéder au document" } } }, role: { choices: { reader: { title: "Lecture seule" }, editor: { title: "Édition" } } } } }, laGaufre: { label: "Services de la Suite numérique", closeLabel: "Fermer le menu", loadingText: "Chargement…", viewMoreLabel: "Voir plus", viewLessLabel: "Voir moins", newWindowLabelSuffix: " (nouvelle fenêtre)", headerLabel: "À propos" }, userMenu: { term_of_service: "Conditions d'utilisation", open: "Ouvrir le menu utilisateur", close: "Fermer le menu utilisateur", accountSettings: "Paramètres du compte", logout: "Déconnexion", dialogTitle: "Menu utilisateur", manage_account: "Gérer le compte" }, treeView: { viewMore: "Charger plus d'éléments" }, footer: { logo: { alt: "Logo du gouvernement français" } }, onboarding: { skip: "Passer", next: "Suivant", previous: "Précédent", complete: "Compris", stepLabel: "Étape {current} sur {total} : {title}", currentStepSuffix: " (actuelle)", contentRegionLabel: "Contenu de l'étape {current} sur {total}" } }, B1 = { - components: N1 - }; - function Qi(t) { -@@ -21568,9 +21568,7 @@ const La = ({ - (X) => !I.includes(X) - ); - let V = m(E !== "" ? "components.share.user.no_result" : "components.share.user.placeholder"); -- const Q = ((X) => !!X.match( -- /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z\-0-9]{2,}))$/ -- ))(E ?? "") && !(R != null && R.some((X) => X.email === E)), te = { -+ const Q = false, te = { - id: E, - full_name: "", - email: E diff --git a/client/src/components/BoardActions/BoardActions.jsx b/client/src/components/BoardActions/BoardActions.jsx index 4ef0ecb8..235a7297 100644 --- a/client/src/components/BoardActions/BoardActions.jsx +++ b/client/src/components/BoardActions/BoardActions.jsx @@ -52,7 +52,6 @@ const BoardActions = React.memo( const [t] = useTranslation(); const [isShareModalOpen, setIsShareModalOpen] = useState(false); - const [isBoardActionsPopoverOpen, setIsBoardActionsPopoverOpen] = useState(false); const BoardActionsPopover = usePopup(BoardActionsStep); @@ -235,7 +234,6 @@ const BoardActions = React.memo( onDelete={() => {}} onLeave={() => {}} onExportCsv={handleExportCsv} - onOpenChange={setIsBoardActionsPopoverOpen} >