Compare commits

..

11 Commits

Author SHA1 Message Date
Anthony LC 3cb7aeb7ec 💩(backend) add document content endpoint
Get the content of a document in markdown format.
Ex: http://localhost:8071/api/v1.0/documents/<ID>/content/
2025-06-03 12:12:19 +02:00
Anthony LC 23860065e1 env.example 2025-06-03 11:19:18 +02:00
Anthony LC f459c56121 📝(mcp) add doc to use mcp with vscode 2025-05-30 17:48:16 +02:00
Quentin BEY 4a81e1526e 👷(hackdays) publish the MCP docker image
Publish the MCP Docker image on our registry.
2025-05-28 11:29:38 +02:00
Quentin BEY abcd61cf2f 👷(hackdays) publish the docker image
Publish the Docker images to deploy on a dedicated instance for the
Hackdays.
2025-05-28 11:29:38 +02:00
Quentin BEY c1a591fb4f 🧱(mcp) add server deployment
Provide the helm chart declaration to deploy the MCP server.
2025-05-28 11:29:38 +02:00
Quentin BEY 83d8478b5d 💩(mcp) add a local MCP server configuration
This provides a way to start a local MCP server:
 - provided a user token, the MCP can create document
 - can be run locally and work with cursor or mcphost
2025-05-28 11:29:38 +02:00
Quentin BEY 6bd136c76e 💩(user-tokens) add back & front for Token auth
This provides:
 - a frontend to allow user to create/delete User Token
 - the authentication process to allow any API to be called when
   authenticating with a User Token.
2025-05-26 14:51:08 +02:00
Quentin BEY e929fcc682 💩(resource-server) open all APIs to RS
This provides a base configuration to allow to access all
API via OIDC resource server authentication.
2025-05-26 11:39:43 +02:00
Quentin BEY fa819bc1ff 🐛(auth) allow several auth backend on m2m API
The previous `ServerToServerAuthentication` was raising authentication
failed error if anything is wrong (the header, the token) which prevents
any possibility to have several authentication backends.
2025-05-26 11:39:43 +02:00
Quentin BEY 43e529da2a 🔒️(oidc) disable OIDC authentication on API
Our authentication flow uses the Django authentication which creates a
session for the User. Then the session is used to make API calls,
therefore we don't need to accept OIDC tokens directly on the API.

Accepting the OIDC token on the API can allow to bypass the "resource
server mode" which allows to restrict provided information according to
the Service Provider which makes the request.
2025-05-26 11:39:43 +02:00
145 changed files with 3367 additions and 1983 deletions
-1
View File
@@ -34,4 +34,3 @@ db.sqlite3
# Frontend
node_modules
.next
-23
View File
@@ -1,23 +0,0 @@
# Set the default behavior for all files
* text=auto eol=lf
# Binary files (should not be modified)
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.flv binary
*.fla binary
*.swf binary
*.gz binary
*.zip binary
*.7z binary
*.ttf binary
*.woff binary
*.woff2 binary
*.eot binary
*.pdf binary
+4 -15
View File
@@ -1,22 +1,11 @@
## Purpose
Describe the purpose of this pull request.
Description...
## Proposal
- [ ] item 1...
- [ ] item 2...
Description...
## External contributions
Thank you for your contribution! 🎉
Please ensure the following items are checked before submitting your pull request:
- [ ] I have read and followed the [contributing guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md)
- [ ] I have read and agreed to the [Code of Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md)
- [ ] I have signed off my commits with `git commit --signoff` (DCO compliance)
- [ ] I have signed my commits with my SSH or GPG key (`git commit -S`)
- [ ] My commit messages follow the required format: `<gitmoji>(type) title description`
- [ ] I have added a changelog entry under `## [Unreleased]` section (if noticeable change)
- [ ] I have added corresponding tests for new features or bug fixes (if applicable)
- [] item 1...
- [] item 2...
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '22.x'
node_version: '20.x'
with-front-dependencies-installation: true
synchronize-with-crowdin:
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '22.x'
node_version: '20.x'
with-front-dependencies-installation: true
with-build_mails: true
+1 -1
View File
@@ -5,7 +5,7 @@ on:
inputs:
node_version:
required: false
default: '22.x'
default: '20.x'
type: string
with-front-dependencies-installation:
type: boolean
+27 -14
View File
@@ -5,13 +5,7 @@ on:
workflow_dispatch:
push:
branches:
- 'main'
tags:
- 'v*'
pull_request:
branches:
- 'main'
- 'ci/trivy-fails'
- 'do-not-merge/hackathon-2025'
env:
DOCKER_USER: 1001:127
@@ -31,7 +25,6 @@ jobs:
images: lasuite/impress-backend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
@@ -43,10 +36,10 @@ jobs:
name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: .
target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -64,7 +57,6 @@ jobs:
images: lasuite/impress-frontend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
@@ -76,13 +68,13 @@ jobs:
name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -100,7 +92,6 @@ jobs:
images: lasuite/impress-y-provider
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
@@ -112,11 +103,34 @@ jobs:
name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: .
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-mcp-server:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-mcp-server
- name: Login to DockerHub
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: ./src/mcp_server
file: ./src/mcp_server/Dockerfile
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -125,7 +139,6 @@ jobs:
- build-and-push-frontend
- build-and-push-backend
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify
+7 -7
View File
@@ -13,7 +13,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '22.x'
node_version: '20.x'
with-front-dependencies-installation: true
test-front:
@@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
@@ -48,7 +48,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
with:
@@ -70,7 +70,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
@@ -86,7 +86,7 @@ jobs:
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
- name: Start Docker services
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
@@ -109,7 +109,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
@@ -125,7 +125,7 @@ jobs:
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
- name: Start Docker services
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
-3
View File
@@ -123,9 +123,6 @@ jobs:
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
redis:
image: redis:5
env:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: impress.settings
-22
View File
@@ -8,28 +8,6 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) add customization for translations #857
- 📝(project) add troubleshoot doc #1066
- 📝(project) add system-requirement doc #1066
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084
### Changed
- 📌(yjs) stop pinning node to minor version on yjs docker image #1005
- 🧑‍💻(docker) add .next to .dockerignore #1055
- 🧑‍💻(docker) handle frontend development images with docker compose #1033
- 🧑‍💻(docker) add y-provider config to development environment #1057
### Fixed
-🐛(frontend) table of content disappearing #982
-🐛(frontend) fix multiple EmojiPicker #1012
-🐛(frontend) fix meta title #1017
-🔧(git) set LF line endings for all text files #1032
-📝(docs) minor fixes to docs/env.md
## [3.3.0] - 2025-05-06
### Added
+14 -47
View File
@@ -39,7 +39,6 @@ DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
@@ -75,39 +74,22 @@ create-env-files: \
env.d/development/kc_postgresql
.PHONY: create-env-files
pre-bootstrap: \
bootstrap: ## Prepare Docker images for the project
bootstrap: \
data/media \
data/static \
create-env-files
.PHONY: pre-bootstrap
post-bootstrap: \
create-env-files \
build \
migrate \
demo \
back-i18n-compile \
mails-install \
mails-build
.PHONY: post-bootstrap
bootstrap: ## Prepare Docker developmentimages for the project
bootstrap: \
pre-bootstrap \
build \
post-bootstrap \
mails-build \
run
.PHONY: bootstrap
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
bootstrap-e2e: \
pre-bootstrap \
build-e2e \
post-bootstrap \
run-e2e
.PHONY: bootstrap-e2e
# -- Docker/compose
build: cache ?=
build: cache ?= --no-cache
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@@ -121,23 +103,16 @@ build-backend: ## build the app-dev container
build-yjs-provider: cache ?=
build-yjs-provider: ## build the y-provider container
@$(COMPOSE) build y-provider-development $(cache)
@$(COMPOSE) build y-provider $(cache)
.PHONY: build-yjs-provider
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend-development $(cache)
@$(COMPOSE) build frontend $(cache)
.PHONY: build-frontend
build-e2e: cache ?=
build-e2e: ## build the e2e container
@$(MAKE) build-backend cache=$(cache)
@$(COMPOSE_E2E) build frontend $(cache)
@$(COMPOSE_E2E) build y-provider $(cache)
.PHONY: build-e2e
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE_E2E) down
@$(COMPOSE) down
.PHONY: down
logs: ## display app-dev logs (follow mode)
@@ -146,30 +121,22 @@ logs: ## display app-dev logs (follow mode)
run-backend: ## Start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider-development
@$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend
run: ## start the wsgi (production) and development server
run:
@$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend-development
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
run-e2e: ## start the e2e server
run-e2e:
@$(MAKE) run-backend
@$(COMPOSE_E2E) stop y-provider-development
@$(COMPOSE_E2E) up --force-recreate -d frontend
@$(COMPOSE_E2E) up --force-recreate -d y-provider
.PHONY: run-e2e
status: ## an alias for "docker compose ps"
@$(COMPOSE_E2E) ps
@$(COMPOSE) ps
.PHONY: status
stop: ## stop the development server using Docker
@$(COMPOSE_E2E) stop
@$(COMPOSE) stop
.PHONY: stop
# -- Backend
@@ -348,7 +315,7 @@ frontend-lint: ## run the frontend linter
.PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend frontend-development
@$(COMPOSE) stop frontend
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development
+3 -3
View File
@@ -93,11 +93,11 @@ The easiest way to start working on the project is to use [GNU Make](https://www
$ make bootstrap FLUSH_ARGS='--no-input'
```
This command builds the `app-dev` and `frontend-dev` containers, installs dependencies, performs database migrations and compiles translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
This command builds the `app` container, installs dependencies, performs database migrations and compiles translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
Your Docker services should now be up and running 🎉
You can access the project by going to <http://localhost:3000>.
You can access to the project by going to <http://localhost:3000>.
You will be prompted to log in. The default credentials are:
@@ -106,7 +106,7 @@ username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponymous Make rule:
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```shellscript
$ make run
+1 -1
View File
@@ -18,7 +18,7 @@ the following command inside your docker container:
## [3.3.0] - 2025-05-22
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/docs/env.md) for more information.
The footer is now configurable from a customization file. To override the default one, you can
use the `THEME_CUSTOMIZATION_FILE_PATH` environment variable to point to your customization file.
+10 -1
View File
@@ -39,10 +39,19 @@ docker_build(
]
)
docker_build(
'localhost:5001/impress-mcp-server:latest',
context='../src/mcp_server',
dockerfile='../src/mcp_server/Dockerfile',
)
k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql'])
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate'])
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
# helmfile in docker mount the current working directory and the helmfile.yaml
# requires the keycloak config in another directory
k8s_yaml(local('cd .. && helmfile -n impress -e ${DEV_ENV:-dev} template --file ./src/helm/helmfile.yaml'))
migration = '''
set -eu
+1 -1
View File
@@ -6,7 +6,7 @@ REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/compose.yml"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
# _set_user: set (or unset) default user id used to run docker commands
-28
View File
@@ -1,28 +0,0 @@
services:
frontend:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
PUBLISH_AS_MIT: "false"
SW_DEACTIVATED: "true"
image: impress:frontend-production
ports:
- "3000:3000"
y-provider:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
image: impress:y-provider-production
restart: unless-stopped
env_file:
- env.d/development/common
ports:
- "4444:4444"
+41 -14
View File
@@ -98,6 +98,40 @@ services:
depends_on:
- app-dev
app:
build:
context: .
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: impress:backend-production
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
celery:
user: ${DOCKER_USER:-1000}
image: impress:backend-production
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
depends_on:
- app
nginx:
image: nginx:1.25
ports:
@@ -107,25 +141,23 @@ services:
depends_on:
app-dev:
condition: service_started
y-provider:
condition: service_started
keycloak:
condition: service_healthy
restart: true
frontend-development:
frontend:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
PUBLISH_AS_MIT: "false"
SW_DEACTIVATED: "true"
image: impress:frontend-development
volumes:
- ./src/frontend:/home/frontend
- /home/frontend/node_modules
- /home/frontend/apps/impress/node_modules
ports:
- "3000:3000"
@@ -139,29 +171,24 @@ services:
working_dir: /app
node:
image: node:22
image: node:18
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
volumes:
- ".:/app"
y-provider-development:
y-provider:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider-development
image: impress:y-provider-development
target: y-provider
restart: unless-stopped
env_file:
- env.d/development/common
ports:
- "4444:4444"
volumes:
- ./src/frontend/:/home/frontend
- /home/frontend/node_modules
- /home/frontend/servers/y-provider/node_modules
kc_postgresql:
image: postgres:14.3
+3 -3
View File
@@ -60,7 +60,7 @@
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.test",
"email": "user@chromium.e2e",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": true,
@@ -74,7 +74,7 @@
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.test",
"email": "user@webkit.e2e",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": true,
@@ -88,7 +88,7 @@
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.test",
"email": "user@firefox.e2e",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": true,
+96 -96
View File
@@ -6,102 +6,102 @@ Here we describe all environment variables that can be set for the docs applicat
These are the environment variables you can set for the `impress-backend` container.
| Option | Description | default |
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_FEATURE_ENABLED | Enable AI options | false |
| AI_MODEL | AI Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CRISP_WEBSITE_ID | Crisp website id for support | |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_HOST | Host of the database | localhost |
| DB_NAME | Name of the database | impress |
| DB_PASSWORD | Password to authenticate with | pass |
| DB_PORT | Port of the database | 5432 |
| DB_USER | User to authenticate with | dinum |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
| DJANGO_EMAIL_HOST | Hostname of email | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_SECRET_KEY | Secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGIN_REDIRECT_URL | Login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
| LOGOUT_REDIRECT_URL | Logout redirect url | |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| MEDIA_BASE_URL | | |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_CREATE_USER | Create used on OIDC | false |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| POSTHOG_KEY | Posthog key for analytics | |
| REDIS_URL | Cache url | redis://redis:6379/1 |
| SENTRY_DSN | Sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
| Option | Description | default |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
| DJANGO_SECRET_KEY | secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_NAME | name of the database | impress |
| DB_USER | user to authenticate with | dinum |
| DB_PASSWORD | password to authenticate with | pass |
| DB_HOST | host of the database | localhost |
| DB_PORT | port of the database | 5432 |
| MEDIA_BASE_URL | | |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | |
| AWS_S3_REGION_NAME | region name for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage |
| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 |
| LANGUAGE_CODE | default language | en-us |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour |
| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 |
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | brand name for email | |
| DJANGO_EMAIL_HOST | host name of email | |
| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | logo for the email | |
| DJANGO_EMAIL_PORT | port used to connect to email host | |
| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false |
| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false |
| DJANGO_EMAIL_FROM | email address used as sender | from@example.com |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | true |
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
| SENTRY_DSN | sentry host | |
| COLLABORATION_API_URL | collaboration api host | |
| COLLABORATION_SERVER_SECRET | collaboration api secret | |
| COLLABORATION_WS_URL | collaboration websocket url | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
| FRONTEND_THEME | frontend theme to use | |
| POSTHOG_KEY | posthog key for analytics | |
| CRISP_WEBSITE_ID | crisp website id for support | |
| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| OIDC_CREATE_USER | create used on OIDC | false |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_RP_CLIENT_ID | client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
| LOGIN_REDIRECT_URL | login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
| LOGOUT_REDIRECT_URL | logout redirect url | |
| OIDC_USE_NONCE | use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_MODEL | AI Model to use | |
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_FEATURE_ENABLED | Enable AI options | false |
| Y_PROVIDER_API_KEY | Y provider API key | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| REDIS_URL | cache url | redis://redis:6379/1 |
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
## impress-frontend image
+3 -3
View File
@@ -91,7 +91,7 @@ extraDeploy:
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.test",
"email": "user@chromium.e2e",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": "true",
@@ -105,7 +105,7 @@ extraDeploy:
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.test",
"email": "user@webkit.e2e",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": "true",
@@ -119,7 +119,7 @@ extraDeploy:
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.test",
"email": "user@firefox.e2e",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": "true",
-110
View File
@@ -1,110 +0,0 @@
# La Suite Docs System & Requirements (2025-06)
## 1. Quick-Reference Matrix (single VM / laptop)
| Scenario | RAM | vCPU | SSD | Notes |
| ------------------------- | ----- | ---- | ------- | ------------------------- |
| **Solo dev** | 8 GB | 4 | 15 GB | Hot-reload + one IDE |
| **Team QA** | 16 GB | 6 | 30 GB | Runs integration tests |
| **Prod ≤ 100 live users** | 32 GB | 8 + | 50 GB + | Scale linearly above this |
Memory is the first bottleneck; CPU matters only when Celery or the Next.js build is saturated.
> **Note:** Memory consumption varies by operating system. Windows tends to be more memory-hungry than Linux, so consider adding 10-20% extra RAM when running on Windows compared to Linux-based systems.
## 2. Development Environment Memory Requirements
| Service | Typical use | Rationale / source |
| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------- |
| PostgreSQL | **1 2 GB** | `shared_buffers` starting point ≈ 25% RAM ([postgresql.org][1]) |
| Keycloak | **≈ 1.3 GB** | 70% of limit for heap + ~300 MB non-heap ([keycloak.org][2]) |
| Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) |
| MinIO | **2 GB (dev) / 32 GB (prod)**| Pre-allocates 12 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) |
| Django API (+ Celery) | **0.8 1.5 GB** | Empirical in-house metrics |
| Next.js frontend | **0.5 1 GB** | Dev build chain |
| Y-Provider (y-websocket) | **< 200 MB** | Large 40 MB YDoc called “big” in community thread ([discuss.yjs.dev][5]) |
| Nginx | **< 100 MB** | Static reverse-proxy footprint |
[1]: https://www.postgresql.org/docs/9.1/runtime-config-resource.html "PostgreSQL: Documentation: 9.1: Resource Consumption"
[2]: https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing "Concepts for sizing CPU and memory resources - Keycloak"
[3]: https://stackoverflow.com/questions/45233052/memory-footprint-for-redis-empty-instance "Memory footprint for Redis empty instance - Stack Overflow"
[4]: https://min.io/docs/minio/kubernetes/upstream/operations/checklists/hardware.html "Hardware Checklist — MinIO Object Storage for Kubernetes"
[5]: https://discuss.yjs.dev/t/understanding-memory-requirements-for-production-usage/198 "Understanding memory requirements for production usage - Yjs Community"
> **Rule of thumb:** add 2 GB for OS/overhead, then sum only the rows you actually run.
## 3. Production Environment Memory Requirements
Production deployments differ significantly from development environments. The table below shows typical memory usage for production services:
| Service | Typical use | Rationale / notes |
| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------- |
| PostgreSQL | **2 8 GB** | Higher `shared_buffers` and connection pooling for concurrent users |
| OIDC Provider (optional) | **Variable** | Any OIDC-compatible provider (Keycloak, Auth0, Azure AD, etc.) - external or self-hosted |
| Redis | **256 MB 2 GB** | Session storage and caching; scales with active user sessions |
| Object Storage (optional)| **External or self-hosted** | Can use AWS S3, Azure Blob, Google Cloud Storage, or self-hosted MinIO |
| Django API (+ Celery) | **1 3 GB** | Production workloads with background tasks and higher concurrency |
| Static Files (Nginx) | **< 200 MB** | Serves Next.js build output and static assets; no development overhead |
| Y-Provider (y-websocket) | **200 MB 1 GB** | Scales with concurrent document editing sessions |
| Nginx (Load Balancer) | **< 200 MB** | Reverse proxy, SSL termination, static file serving |
### Production Architecture Notes
- **Frontend**: Uses pre-built Next.js static assets served by Nginx (no Node.js runtime needed)
- **Authentication**: Any OIDC-compatible provider can be used instead of self-hosted Keycloak
- **Object Storage**: External services (S3, Azure Blob) or self-hosted solutions (MinIO) are both viable
- **Database**: Consider PostgreSQL clustering or managed database services for high availability
- **Scaling**: Horizontal scaling is recommended for Django API and Y-Provider services
### Minimal Production Setup (Core Services Only)
| Service | Memory | Notes |
| ------------------------ | --------- | --------------------------------------- |
| PostgreSQL | **2 GB** | Core database |
| Django API (+ Celery) | **1.5 GB**| Backend services |
| Y-Provider | **200 MB**| Real-time collaboration |
| Nginx | **100 MB**| Static files + reverse proxy |
| Redis | **256 MB**| Session storage |
| **Total (without auth/storage)** | **≈ 4 GB** | External OIDC + object storage assumed |
## 4. Recommended Software Versions
| Tool | Minimum |
| ----------------------- | ------- |
| Docker Engine / Desktop | 24.0 |
| Docker Compose | v2 |
| Git | 2.40 |
| **Node.js** | 22+ |
| **Python** | 3.13+ |
| GNU Make | 4.4 |
| Kind | 0.22 |
| Helm | 3.14 |
| kubectl | 1.29 |
| mkcert | 1.4 |
## 5. Ports (dev defaults)
| Port | Service |
| --------- | --------------------- |
| 3000 | Next.js |
| 8071 | Django |
| 4444 | Y-Provider |
| 8080 | Keycloak |
| 8083 | Nginx proxy |
| 9000/9001 | MinIO |
| 15432 | PostgreSQL (main) |
| 5433 | PostgreSQL (Keycloak) |
| 1081 | MailCatcher |
## 6. Sizing Guidelines
**RAM** start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.
> **OS considerations:** Windows systems typically require 10-20% more RAM than Linux due to higher OS overhead. Docker Desktop on Windows also uses additional memory compared to native Linux Docker.
**CPU** budget one vCPU per busy container until Celery or Next.js builds saturate.
**Disk** SSD; add 10 GB extra for the Docker layer cache.
**MinIO** for demos, mount a local folder instead of running MinIO to save 2 GB+ of RAM.
-14
View File
@@ -53,18 +53,4 @@ Below is a visual example of a configured footer ⬇️:
![Footer Configuration Example](./assets/footer-configurable.png)
----
# **Custom Translations** 📝
The translations can be partially overridden from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
-194
View File
@@ -1,194 +0,0 @@
# Troubleshooting Guide
## Line Ending Issues on Windows (LF/CRLF)
### Problem Description
This project uses **LF (Line Feed: `\n`) line endings** exclusively. Windows users may encounter issues because:
- **Windows** defaults to CRLF (Carriage Return + Line Feed: `\r\n`) for line endings
- **This project** uses LF line endings for consistency across all platforms
- **Git** may automatically convert line endings, causing conflicts or build failures
### Common Symptoms
- Git shows files as modified even when no changes were made
- Error messages like "warning: LF will be replaced by CRLF"
- Build failures or linting errors due to line ending mismatches
### Solutions for Windows Users
#### Configure Git to Preserve LF (Recommended)
Configure Git to NOT convert line endings and preserve LF:
```bash
git config core.autocrlf false
git config core.eol lf
```
This tells Git to:
- Never convert line endings automatically
- Always use LF for line endings in working directory
#### Fix Existing Repository with Wrong Line Endings
If you already have CRLF line endings in your local repository, the **best approach** is to configure Git properly and clone the project again:
1. **Configure Git first**:
```bash
git config --global core.autocrlf false
git config --global core.eol lf
```
2. **Clone the project fresh** (recommended):
```bash
# Navigate to parent directory
cd ..
# Remove current repository (backup your changes first!)
rm -rf docs
# Clone again with correct line endings
git clone git@github.com:suitenumerique/docs.git
```
**Alternative**: If you have uncommitted changes and cannot re-clone:
1. **Backup your changes**:
```bash
git add .
git commit -m "Save changes before fixing line endings"
```
2. **Remove all files from Git's index**:
```bash
git rm --cached -r .
```
3. **Reset Git configuration** (if not done globally):
```bash
git config core.autocrlf false
git config core.eol lf
```
4. **Re-add all files** (Git will use LF line endings):
```bash
git add .
```
5. **Commit the changes**:
```bash
git commit -m "✏️(project) Fix line endings to LF"
```
## Minio Permission Issues on Windows
### Problem Description
On Windows, you may encounter permission-related errors when running Minio in development mode with Docker Compose. This typically happens because:
- **Windows file permissions** don't map well to Unix-style user IDs used in Docker containers
- **Docker Desktop** may have issues with user mapping when using the `DOCKER_USER` environment variable
- **Minio container** fails to start or access volumes due to permission conflicts
### Common Symptoms
- Minio container fails to start with permission denied errors
- Error messages related to file system permissions in Minio logs
- Unable to create or access buckets in the development environment
- Docker Compose showing Minio service as unhealthy or exited
### Solution for Windows Users
If you encounter Minio permission issues on Windows, you can temporarily disable user mapping for the Minio service:
1. **Open the `compose.yml` file**
2. **Comment out the user directive** in the `minio` service section:
```yaml
minio:
# user: ${DOCKER_USER:-1000} # Comment this line on Windows if permission issues occur
image: minio/minio
environment:
- MINIO_ROOT_USER=impress
- MINIO_ROOT_PASSWORD=password
# ... rest of the configuration
```
3. **Restart the services**:
```bash
make run
```
### Why This Works
- Commenting out the `user` directive allows the Minio container to run with its default user
- This bypasses Windows-specific permission mapping issues
- The container will have the necessary permissions to access and manage the mounted volumes
### Note
This is a **development-only workaround**. In production environments, proper user mapping and security considerations should be maintained according to your deployment requirements.
## Frontend File Watching Issues on Windows
### Problem Description
Windows users may experience issues with file watching in the frontend-development container. This typically happens because:
- **Docker on Windows** has known limitations with file change detection
- **Node.js file watchers** may not detect changes properly on Windows filesystem
- **Hot reloading** fails to trigger when files are modified
### Common Symptoms
- Changes to frontend code aren't detected automatically
- Hot module replacement doesn't work as expected
- Need to manually restart the frontend container after code changes
- Console shows no reaction when saving files
### Solution: Enable WATCHPACK_POLLING
Add the `WATCHPACK_POLLING=true` environment variable to the frontend-development service in your local environment:
1. **Modify the `compose.yml` file** by adding the environment variable to the frontend-development service:
```yaml
frontend-development:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
args:
API_ORIGIN: "http://localhost:8071"
PUBLISH_AS_MIT: "false"
SW_DEACTIVATED: "true"
image: impress:frontend-development
environment:
- WATCHPACK_POLLING=true # Add this line for Windows users
volumes:
- ./src/frontend:/home/frontend
- /home/frontend/node_modules
- /home/frontend/apps/impress/node_modules
ports:
- "3000:3000"
```
2. **Restart your containers**:
```bash
make run
```
### Why This Works
- `WATCHPACK_POLLING=true` forces the file watcher to use polling instead of filesystem events
- Polling periodically checks for file changes rather than relying on OS-level file events
- This is more reliable on Windows but slightly increases CPU usage
- Changes to your frontend code should now be detected properly, enabling hot reloading
### Note
This setting is primarily needed for Windows users. Linux and macOS users typically don't need this setting as file watching works correctly by default on those platforms.
-3
View File
@@ -61,6 +61,3 @@ COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
+2
View File
@@ -2,3 +2,5 @@
BURST_THROTTLE_RATES="200/minute"
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
Y_PROVIDER_API_KEY=yprovider-api-key
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
+7 -1
View File
@@ -1,7 +1,7 @@
{
"extends": ["github>numerique-gouv/renovate-configuration"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog", "automated"],
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
{
"enabled": false,
@@ -9,6 +9,12 @@
"matchManagers": ["pep621"],
"matchPackageNames": []
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["Django"],
"allowedVersions": "<5.2"
},
{
"groupName": "allowed redis versions",
"matchManagers": ["pep621"],
+31 -35
View File
@@ -10,7 +10,7 @@ from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import cache, caches
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import connection, transaction
@@ -25,13 +25,15 @@ from django.utils.translation import gettext_lazy as _
import requests
import rest_framework as drf
from botocore.exceptions import ClientError
from knox.auth import TokenAuthentication
from lasuite.malware_detection import malware_detection
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import UserRateThrottle
from core import authentication, enums, models
from core import authentication, enums, models, utils as core_utils
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.utils import extract_attachments, filter_descendants
@@ -404,7 +406,7 @@ class DocumentViewSet(
Example:
- Ascending: GET /api/v1.0/documents/?ordering=created_at
- Descending: GET /api/v1.0/documents/?ordering=-title
- Desceding: GET /api/v1.0/documents/?ordering=-title
### Filtering:
- `is_creator_me=true`: Returns documents created by the current user.
@@ -430,9 +432,7 @@ class DocumentViewSet(
ordering = ["-updated_at"]
ordering_fields = ["created_at", "updated_at", "title"]
pagination_class = Pagination
permission_classes = [
permissions.DocumentAccessPermission,
]
permission_classes = [permissions.DocumentAccessPermission]
queryset = models.Document.objects.all()
serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
@@ -631,33 +631,6 @@ class DocumentViewSet(
"""Override to implement a soft delete instead of dumping the record in database."""
instance.soft_delete()
def perform_update(self, serializer):
"""Check rules about collaboration."""
shared_cache = caches["shared"]
cache_key = f"docs:state:{serializer.instance.id}"
doc_state = shared_cache.get(cache_key, enums.DEFAULT_DOCS_STATE.copy())
session_key = self.request.session.session_key
if doc_state["wsUsers"] and not session_key in doc_state["wsUsers"]:
raise drf.exceptions.PermissionDenied(
"You are not allowed to edit this document."
)
if doc_state["httpUser"] and doc_state["httpUser"] != session_key:
raise drf.exceptions.PermissionDenied(
"You are not allowed to edit this document."
)
if doc_state["httpUser"] is None:
doc_state["httpUser"] = session_key
shared_cache.set(cache_key, doc_state)
shared_cache.touch(cache_key)
return super().perform_update(serializer)
@drf.decorators.action(
detail=False,
methods=["get"],
@@ -696,10 +669,14 @@ class DocumentViewSet(
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
authentication_classes=[authentication.ServerToServerAuthentication],
authentication_classes=[
authentication.ServerToServerAuthentication,
ResourceServerAuthentication,
TokenAuthentication,
],
detail=False,
methods=["post"],
permission_classes=[],
permission_classes=[permissions.IsAuthenticated],
url_path="create-for-owner",
)
@transaction.atomic
@@ -1376,6 +1353,25 @@ class DocumentViewSet(
}
return drf.response.Response(body, status=drf.status.HTTP_200_OK)
@drf.decorators.action(detail=True, methods=["get"], url_path="content")
def content(self, request, *args, **kwargs):
"""
Get the content of a document
"""
document = self.get_object()
# content_type = response.headers.get("Content-Type", "")
base64_yjs_content = document.content
content = core_utils.base64_yjs_to_markdown(base64_yjs_content)
body = {
"content": content,
}
return drf.response.Response(body, status=drf.status.HTTP_200_OK)
@drf.decorators.action(
detail=True,
+15 -3
View File
@@ -6,6 +6,15 @@ from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class AuthenticatedServer:
"""
Simple class to represent an authenticated server to be used along the
IsAuthenticated permission.
"""
is_authenticated = True
class ServerToServerAuthentication(BaseAuthentication):
"""
Custom authentication class for server-to-server requests.
@@ -39,13 +48,16 @@ class ServerToServerAuthentication(BaseAuthentication):
# Validate token format and existence
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
raise AuthenticationFailed("Invalid authorization header.")
# Do not raise here to leave the door open for other authentication methods
return None
token = auth_parts[1]
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
raise AuthenticationFailed("Invalid server-to-server token.")
# Do not raise here to leave the door open for other authentication methods
return None
# Authentication is successful, but no user is authenticated
# Authentication is successful
return AuthenticatedServer(), token
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
-10
View File
@@ -1,10 +0,0 @@
"""Cache utilities"""
# pylint: disable=unused-argument
def shared_key_func(key: str, key_prefix: str, version: int = 1) -> str:
"""
Compute key for shared cache. In order to be compatiable with other system,
only the key is used.
"""
return key
-5
View File
@@ -22,11 +22,6 @@ MEDIA_STORAGE_URL_EXTRACT = re.compile(
f"{settings.MEDIA_URL:s}({UUID_REGEX}/{ATTACHMENTS_FOLDER}/{UUID_REGEX}{FILE_EXT_REGEX})"
)
DEFAULT_DOCS_STATE = {
"httpUser": None,
"wsUsers": [],
}
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
# We can use it for the choice of languages which should not be limited to the few languages
-2
View File
@@ -35,8 +35,6 @@ class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.User
# Skip postgeneration save, no save is made in the postgeneration methods.
skip_postgeneration_save = True
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
-21
View File
@@ -1,21 +0,0 @@
"""Force session creation for all requests."""
class ForceSessionMiddleware:
"""
Force session creation for unauthenticated users.
Must be used after Authentication middleware.
"""
def __init__(self, get_response):
"""Initialize the middleware."""
self.get_response = get_response
def __call__(self, request):
"""Force session creation for unauthenticated users."""
if not request.user.is_authenticated and request.session.session_key is None:
request.session.save()
response = self.get_response(request)
return response
+2 -2
View File
@@ -504,7 +504,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint(
model_name="documentaccess",
constraint=models.CheckConstraint(
condition=models.Q(
check=models.Q(
models.Q(("team", ""), ("user__isnull", False)),
models.Q(("team__gt", ""), ("user__isnull", True)),
_connector="OR",
@@ -540,7 +540,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint(
model_name="templateaccess",
constraint=models.CheckConstraint(
condition=models.Q(
check=models.Q(
models.Q(("team", ""), ("user__isnull", False)),
models.Q(("team__gt", ""), ("user__isnull", True)),
_connector="OR",
+5 -4
View File
@@ -520,7 +520,7 @@ class Document(MP_Node, BaseModel):
verbose_name_plural = _("Documents")
constraints = [
models.CheckConstraint(
condition=(
check=(
models.Q(deleted_at__isnull=True)
| models.Q(deleted_at=models.F("ancestors_deleted_at"))
),
@@ -747,7 +747,7 @@ class Document(MP_Node, BaseModel):
for ancestor in ancestors_links:
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
return dict(links_definitions) # Convert default dict back to a normal dict
return dict(links_definitions) # Convert defaultdict back to a normal dict
def compute_ancestors_links(self, user):
"""
@@ -839,6 +839,7 @@ class Document(MP_Node, BaseModel):
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,
"content": can_get,
"cors_proxy": can_get,
"descendants": can_get,
"destroy": is_owner,
@@ -1088,7 +1089,7 @@ class DocumentAccess(BaseAccess):
violation_error_message=_("This team is already in this document."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
check=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_document_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
@@ -1236,7 +1237,7 @@ class TemplateAccess(BaseAccess):
violation_error_message=_("This team is already in this template."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
check=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_template_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
+2 -3
View File
@@ -2,7 +2,7 @@
from unittest import mock
from django.core.cache import caches
from django.core.cache import cache
import pytest
@@ -14,8 +14,7 @@ VIA = [USER, TEAM]
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
for cache in caches.all():
cache.clear()
cache.clear()
@pytest.fixture
@@ -47,7 +47,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
factories.DocumentFactory(attachments=[image_keys[3]], link_reach="restricted")
expected_keys = {image_keys[i] for i in [0, 1]}
with django_assert_num_queries(11):
with django_assert_num_queries(9):
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys)},
@@ -60,7 +60,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
# Check that the db query to check attachments readability for extracted
# keys is not done if the content changes but no new keys are found
with django_assert_num_queries(7):
with django_assert_num_queries(5):
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys[:2])},
@@ -98,7 +98,7 @@ def test_api_documents_update_new_attachment_keys_authenticated(
factories.DocumentFactory(attachments=[image_keys[4]], users=[user])
expected_keys = {image_keys[i] for i in [0, 1, 2, 4]}
with django_assert_num_queries(12):
with django_assert_num_queries(10):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys)},
@@ -111,7 +111,7 @@ def test_api_documents_update_new_attachment_keys_authenticated(
# Check that the db query to check attachments readability for extracted
# keys is not done if the content changes but no new keys are found
with django_assert_num_queries(8):
with django_assert_num_queries(6):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys[:2])},
@@ -1064,7 +1064,7 @@ def test_models_documents_restore(django_assert_num_queries):
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
with django_assert_num_queries(10):
with django_assert_num_queries(8):
document.restore()
document.refresh_from_db()
assert document.deleted_at is None
@@ -1107,7 +1107,7 @@ def test_models_documents_restore_complex(django_assert_num_queries):
assert child2.ancestors_deleted_at == document.deleted_at
# Restore the item
with django_assert_num_queries(13):
with django_assert_num_queries(11):
document.restore()
document.refresh_from_db()
child1.refresh_from_db()
@@ -1157,7 +1157,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
# Restoring the grand parent should not restore the document
# as it was deleted before the grand parent
with django_assert_num_queries(11):
with django_assert_num_queries(9):
grand_parent.restore()
grand_parent.refresh_from_db()
@@ -0,0 +1,131 @@
"""
Test user_token API endpoints in the impress core app.
"""
import pytest
from knox.models import get_token_model
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
AuthToken = get_token_model()
def test_api_user_token_list_anonymous(client):
"""Anonymous users should not be allowed to list user tokens."""
response = client.get("/api/v1.0/user-tokens/")
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_user_token_list_authenticated(client):
"""
Authenticated users should be able to list their own tokens.
Tokens are identified by digest, and include created/expiry.
"""
user = factories.UserFactory()
# Knox creates a token instance and a character string token key.
# The create method returns a tuple: (instance, token_key_string)
token_instance_1, _ = AuthToken.objects.create(user=user)
AuthToken.objects.create(user=user) # Another token for the same user
AuthToken.objects.create(user=factories.UserFactory()) # Token for a different user
client.force_login(user)
response = client.get("/api/v1.0/user-tokens/")
assert response.status_code == 200
content = response.json()
assert len(content) == 2
# Check that the response contains the digests of the tokens created for the user
response_token_digests = {item["digest"] for item in content}
assert token_instance_1.digest in response_token_digests
# Ensure the token_key is not listed
for item in content:
assert "token_key" not in item
assert "digest" in item
assert "created" in item
assert "expiry" in item
def test_api_user_token_create_anonymous(client):
"""Anonymous users should not be allowed to create user tokens."""
# The create endpoint does not take any parameters as per TokenCreateSerializer
# (user is implicit, other fields are read_only)
response = client.post("/api/v1.0/user-tokens/", data={})
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_user_token_create_authenticated(client):
"""
Authenticated users should be able to create a new token.
The token key should be returned in the response upon creation.
"""
user = factories.UserFactory()
client.force_login(user)
# The create endpoint does not take any parameters as per TokenCreateSerializer
response = client.post("/api/v1.0/user-tokens/", data={})
assert response.status_code == 201
content = response.json()
# Based on TokenCreateSerializer, these fields should be in the response
assert "token_key" in content
assert "digest" in content
assert "created" in content
assert "expiry" in content
assert len(content["token_key"]) > 0 # Knox token key should be non-empty
# Verify the token was actually created in the database for the user
assert AuthToken.objects.filter(user=user, digest=content["digest"]).exists()
def test_api_user_token_destroy_anonymous(client):
"""Anonymous users should not be allowed to delete user tokens."""
user = factories.UserFactory()
token_instance, _ = AuthToken.objects.create(user=user)
response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
assert response.status_code == 403
assert AuthToken.objects.filter(digest=token_instance.digest).exists()
def test_api_user_token_destroy_authenticated_own_token(client):
"""Authenticated users should be able to delete their own tokens."""
user = factories.UserFactory()
token_instance, _ = AuthToken.objects.create(user=user)
client.force_login(user)
response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
assert response.status_code == 204
assert not AuthToken.objects.filter(digest=token_instance.digest).exists()
def test_api_user_token_destroy_authenticated_other_user_token(client):
"""Authenticated users should not be able to delete other users' tokens."""
user = factories.UserFactory()
other_user = factories.UserFactory()
other_user_token_instance, _ = AuthToken.objects.create(user=other_user)
client.force_login(user) # Log in as 'user'
response = client.delete(f"/api/v1.0/user-tokens/{other_user_token_instance.digest}/")
# The default behavior for a non-found or non-permissioned item in DestroyModelMixin
# when the queryset is filtered (as in get_queryset) is often a 404.
assert response.status_code == 404
assert AuthToken.objects.filter(digest=other_user_token_instance.digest).exists()
def test_api_user_token_destroy_non_existent_token(client):
"""Attempting to delete a non-existent token should result in a 404."""
user = factories.UserFactory()
client.force_login(user)
response = client.delete("/api/v1.0/user-tokens/nonexistentdigest/")
assert response.status_code == 404
+8
View File
@@ -4,15 +4,22 @@ from django.conf import settings
from django.urls import include, path, re_path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from lasuite.oidc_resource_server.urls import urlpatterns as resource_server_urls
from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.user_token import viewsets as user_token_viewsets
# - Main endpoints
router = DefaultRouter()
router.register("templates", viewsets.TemplateViewSet, basename="templates")
router.register("documents", viewsets.DocumentViewSet, basename="documents")
router.register("users", viewsets.UserViewSet, basename="users")
router.register(
"user-tokens",
user_token_viewsets.UserTokenViewset,
basename="user_tokens",
)
# - Routes nested under a document
document_related_router = DefaultRouter()
@@ -44,6 +51,7 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*resource_server_urls,
re_path(
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
include(document_related_router.urls),
@@ -0,0 +1,27 @@
from knox.models import get_token_model
from rest_framework import serializers
class TokenReadSerializer(serializers.ModelSerializer):
"""Serialize token for list purpose."""
class Meta:
model = get_token_model()
fields = ["digest", "created", "expiry"]
read_only_fields = ["digest", "created", "expiry"]
class TokenCreateSerializer(serializers.ModelSerializer):
"""Serialize token for creation purpose."""
class Meta:
model = get_token_model()
fields = ["user", "digest", "token_key", "created", "expiry"]
read_only_fields = ["digest", "token_key", "created", "expiry"]
extra_kwargs = {"user": {"write_only": True}}
def create(self, validated_data):
"""The default knox token create manager returns a tuple."""
instance, token = super().create(validated_data)
instance.token_key = token # warning do not save this
return instance
+50
View File
@@ -0,0 +1,50 @@
"""API endpoints for user token management"""
from knox.models import get_token_model
from rest_framework import permissions, viewsets, mixins
from rest_framework.authentication import SessionAuthentication
from . import serializers
class UserTokenViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
This view access is restricted to the session ie from frontend.
GET /api/v1.0/user-token/
Return list of existing tokens.
POST /api/v1.0/user-token/
Return newly created token.
DELETE /api/v1.0/user-token/<token_id>/
Delete targeted token.
"""
authentication_classes = [SessionAuthentication]
pagination_class = None
permission_classes = [permissions.IsAuthenticated]
queryset = get_token_model().objects.all()
serializer_class = serializers.TokenReadSerializer
def get_queryset(self):
"""Return the queryset restricted to the logged-in user."""
queryset = super().get_queryset()
queryset = queryset.filter(user_id=self.request.user.pk)
return queryset
def get_serializer_class(self):
if self.action == "create":
return serializers.TokenCreateSerializer
return super().get_serializer_class()
def create(self, request, *args, **kwargs):
"""Enforce request data to use current user."""
request.data["user"] = self.request.user.pk
return super().create(request, *args, **kwargs)
+110
View File
@@ -66,6 +66,116 @@ def base64_yjs_to_text(base64_string):
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
return soup.get_text(separator=" ", strip=True)
def base64_yjs_to_markdown(base64_string: str) -> str:
xml_content = base64_yjs_to_xml(base64_string)
soup = BeautifulSoup(xml_content, "lxml-xml")
md_lines: list[str] = []
def walk(node) -> None:
if not getattr(node, "name", None):
return
# Treat the synthetic “[document]” tag exactly like a wrapper
if node.name in {"[document]", "blockGroup", "blockContainer"}:
for child in node.find_all(recursive=False):
walk(child)
if node.name == "blockContainer":
md_lines.append("") # paragraph break
return
# ----------- content nodes -------------
if node.name == "heading":
level = int(node.get("level", 1))
md_lines.extend([("#" * level) + " " + process_inline_formatting(node), ""])
elif node.name == "paragraph":
md_lines.extend([process_inline_formatting(node), ""])
elif node.name == "bulletListItem":
md_lines.append("- " + process_inline_formatting(node))
elif node.name == "numberedListItem":
idx = node.get("index", "1")
md_lines.append(f"{idx}. " + process_inline_formatting(node))
elif node.name == "checkListItem":
checked = "x" if node.get("checked") == "true" else " "
md_lines.append(f"- [{checked}] " + process_inline_formatting(node))
elif node.name == "codeBlock":
lang = node.get("language", "")
code = node.get_text("", strip=False)
md_lines.extend([f"```{lang}", code, "```", ""])
elif node.name in {"quote", "blockquote"}:
quote = process_inline_formatting(node)
for line in quote.splitlines() or [""]:
md_lines.append("> " + line)
md_lines.append("")
elif node.name == "divider":
md_lines.extend(["---", ""])
elif node.name == "callout":
emoji = node.get("emoji", "💡")
md_lines.extend([f"> {emoji} {process_inline_formatting(node)}", ""])
elif node.name == "img":
src = node.get("src", "")
alt = node.get("alt", "")
md_lines.extend([f"![{alt}]({src})", ""])
# unknown tags are ignored
# kick-off: start at the synthetic root
walk(soup)
# collapse accidental multiple blank lines
cleaned: list[str] = []
for line in md_lines:
if line == "" and (not cleaned or cleaned[-1] == ""):
continue
cleaned.append(line)
return "\n".join(cleaned).rstrip() + "\n"
def process_inline_formatting(element):
"""
Process inline formatting elements like bold, italic, underline, etc.
and convert them to markdown syntax.
"""
result = ""
# If it's just a text node, return the text
if isinstance(element, str):
return element
# Process children elements
for child in element.contents:
if isinstance(child, str):
result += child
elif hasattr(child, 'name'):
if child.name == "bold":
result += "**" + process_inline_formatting(child) + "**"
elif child.name == "italic":
result += "*" + process_inline_formatting(child) + "*"
elif child.name == "underline":
result += "__" + process_inline_formatting(child) + "__"
elif child.name == "strike":
result += "~~" + process_inline_formatting(child) + "~~"
elif child.name == "code":
result += "`" + process_inline_formatting(child) + "`"
elif child.name == "link":
href = child.get("href", "")
text = process_inline_formatting(child)
result += f"[{text}]({href})"
else:
# For other elements, just process their contents
result += process_inline_formatting(child)
return result
def extract_attachments(content):
"""Helper method to extract media paths from a document's content."""
+3 -3
View File
@@ -8,11 +8,11 @@ NB_OBJECTS = {
DEV_USERS = [
{"username": "impress", "email": "impress@impress.world", "language": "en-us"},
{"username": "user-e2e-webkit", "email": "user@webkit.test", "language": "en-us"},
{"username": "user-e2e-firefox", "email": "user@firefox.test", "language": "en-us"},
{"username": "user-e2e-webkit", "email": "user@webkit.e2e", "language": "en-us"},
{"username": "user-e2e-firefox", "email": "user@firefox.e2e", "language": "en-us"},
{
"username": "user-e2e-chromium",
"email": "user@chromium.test",
"email": "user@chromium.e2e",
"language": "en-us",
},
]
@@ -33,9 +33,9 @@ def test_commands_create_demo():
# assert dev users have doc accesses
user = models.User.objects.get(email="impress@impress.world")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@webkit.test")
user = models.User.objects.get(email="user@webkit.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@firefox.test")
user = models.User.objects.get(email="user@firefox.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@chromium.test")
user = models.User.objects.get(email="user@chromium.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
+76 -63
View File
@@ -9,8 +9,10 @@ https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
# pylint: disable=too-many-lines
import datetime
import os
import tomllib
from socket import gethostbyname, gethostname
@@ -284,7 +286,6 @@ class Base(Configuration):
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"core.middleware.ForceSessionMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
]
@@ -305,6 +306,7 @@ class Base(Configuration):
"django_filters",
"dockerflow.django",
"rest_framework",
"knox",
"parler",
"treebeard",
"easy_thumbnails",
@@ -325,30 +327,13 @@ class Base(Configuration):
# Cache
CACHES = {
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
"shared": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/0",
environ_name="REDIS_URL",
environ_prefix=None,
),
"TIMEOUT": values.IntegerValue(
120, # timeout in seconds
environ_name="SHARED_CACHE_TIMEOUT",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"SERIALIZER": "django_redis.serializers.json.JSONSerializer",
},
"KEY_FUNCTION": "core.cache.shared_key_func",
},
}
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
"rest_framework.authentication.SessionAuthentication",
"knox.auth.TokenAuthentication",
"lasuite.oidc_resource_server.authentication.ResourceServerAuthentication",
),
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
@@ -416,7 +401,7 @@ class Base(Configuration):
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(True)
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
@@ -490,7 +475,6 @@ class Base(Configuration):
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
)
SESSION_COOKIE_NAME = "docs_sessionid"
# OIDC - Authorization Code Flow
OIDC_CREATE_USER = values.BooleanValue(
@@ -614,6 +598,72 @@ class Base(Configuration):
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
# OIDC - Docs as a resource server
OIDC_OP_URL = values.Value(
default=None, environ_name="OIDC_OP_URL", environ_prefix=None
)
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
)
OIDC_VERIFY_SSL = values.BooleanValue(
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
)
OIDC_TIMEOUT = values.IntegerValue(
default=3, environ_name="OIDC_TIMEOUT", environ_prefix=None
)
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
OIDC_RS_BACKEND_CLASS = "lasuite.oidc_resource_server.backend.ResourceServerBackend"
OIDC_RS_AUDIENCE_CLAIM = values.Value( # The claim used to identify the audience
default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
)
OIDC_RS_PRIVATE_KEY_STR = values.Value(
default=None,
environ_name="OIDC_RS_PRIVATE_KEY_STR",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
default="RSA",
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_ALGO = values.Value(
default="RSA-OAEP",
environ_name="OIDC_RS_ENCRYPTION_ALGO",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
default="A256GCM",
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
environ_prefix=None,
)
OIDC_RS_CLIENT_ID = values.Value(
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
)
OIDC_RS_CLIENT_SECRET = values.Value(
None,
environ_name="OIDC_RS_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_RS_SIGNING_ALGO = values.Value(
default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
)
OIDC_RS_SCOPES = values.ListValue(
[], environ_name="OIDC_RS_SCOPES", environ_prefix=None
)
# User token (knox)
REST_KNOX = {
"SECURE_HASH_ALGORITHM": "hashlib.sha512",
"AUTH_TOKEN_CHARACTER_LENGTH": 64,
"TOKEN_TTL": datetime.timedelta(hours=24 * 7),
"TOKEN_LIMIT_PER_USER": None,
"AUTO_REFRESH": False,
"AUTO_REFRESH_MAX_TTL": None,
"MIN_REFRESH_INTERVAL": 60,
"AUTH_HEADER_PREFIX": "Token",
}
# AI service
AI_FEATURE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
@@ -832,6 +882,8 @@ class Development(Base):
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"]
DEBUG = True
SESSION_COOKIE_NAME = "impress_sessionid"
USE_SWAGGER = True
SESSION_CACHE_ALIAS = "session"
CACHES = {
@@ -841,7 +893,7 @@ class Development(Base):
"session": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/0",
"redis://redis:6379/2",
environ_name="REDIS_URL",
environ_prefix=None,
),
@@ -854,24 +906,6 @@ class Development(Base):
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
},
"shared": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/0",
environ_name="REDIS_URL",
environ_prefix=None,
),
"TIMEOUT": values.IntegerValue(
120, # timeout in seconds
environ_name="SHARED_CACHE_TIMEOUT",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"SERIALIZER": "django_redis.serializers.json.JSONSerializer",
},
"KEY_FUNCTION": "core.cache.shared_key_func",
},
}
def __init__(self):
@@ -886,9 +920,6 @@ class Test(Base):
"django.contrib.auth.hashers.MD5PasswordHasher",
]
USE_SWAGGER = True
# Static files are not used in the test environment
# Tests are raising warnings because the /data/static directory does not exist
STATIC_ROOT = None
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
@@ -957,7 +988,7 @@ class Production(Base):
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/0",
"redis://redis:6379/1",
environ_name="REDIS_URL",
environ_prefix=None,
),
@@ -975,24 +1006,6 @@ class Production(Base):
environ_prefix=None,
),
},
"shared": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/0",
environ_name="REDIS_URL",
environ_prefix=None,
),
"TIMEOUT": values.IntegerValue(
120, # timeout in seconds
environ_name="SHARED_CACHE_TIMEOUT",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"SERIALIZER": "django_redis.serializers.json.JSONSerializer",
},
"KEY_FUNCTION": "core.cache.shared_key_func",
},
}
+16 -15
View File
@@ -26,9 +26,9 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.38.36",
"boto3==1.38.23",
"Brotli==1.1.0",
"celery[redis]==5.5.3",
"celery[redis]==5.5.2",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
@@ -36,9 +36,10 @@ dependencies = [
"django-lasuite[all]==0.0.9",
"django-parler==2.3",
"django-redis==5.4.0",
"django-rest-knox==5.0.2",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.3",
"django==5.1.9",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
@@ -46,19 +47,19 @@ dependencies = [
"easy_thumbnails==2.10",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.24.0",
"jsonschema==4.23.0",
"lxml==5.4.0",
"markdown==3.8",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.86.0",
"openai==1.82.0",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.21",
"pycrdt==0.12.20",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.4",
"sentry-sdk==2.30.0",
"requests==2.32.3",
"sentry-sdk==2.29.1",
"whitenoise==6.9.0",
]
@@ -72,21 +73,21 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.6.1",
"drf-spectacular-sidecar==2025.5.1",
"freezegun==1.5.2",
"ipdb==0.13.13",
"ipython==9.3.0",
"ipython==9.2.0",
"pyfakefs==5.8.0",
"pylint-django==2.6.1",
"pylint==3.3.7",
"pytest-cov==6.2.1",
"pytest-cov==6.1.1",
"pytest-django==4.11.1",
"pytest==8.4.0",
"pytest==8.3.5",
"pytest-icdiff==0.9",
"pytest-xdist==3.7.0",
"pytest-xdist==3.6.1",
"responses==0.25.7",
"ruff==0.11.13",
"types-requests==2.32.4.20250611",
"ruff==0.11.11",
"types-requests==2.32.0.20250515",
]
[tool.setuptools]
@@ -119,29 +119,9 @@ test.describe('Config', () => {
.first(),
).toBeAttached();
});
test('it checks theme_customization.translations config', async ({
page,
}) => {
await overrideConfig(page, {
theme_customization: {
translations: {
en: {
translation: {
Docs: 'MyCustomDocs',
},
},
},
},
});
await page.goto('/');
await expect(page.getByText('MyCustomDocs')).toBeAttached();
});
});
test.describe('Config: Not logged', () => {
test.describe('Config: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks the config api is called', async ({ page }) => {
@@ -31,7 +31,7 @@ test.describe('Doc Create', () => {
});
});
test.describe('Doc Create: Not logged', () => {
test.describe('Doc Create: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it creates a doc server way', async ({
@@ -44,8 +44,8 @@ test.describe('Doc Create: Not logged', () => {
const data = {
title,
content: markdown,
sub: `user@${browserName}.test`,
email: `user@${browserName}.test`,
sub: `user@${browserName}.e2e`,
email: `user@${browserName}.e2e`,
};
const newDoc = await request.post(
@@ -95,7 +95,7 @@ test.describe('Doc Editor', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
// When the visibility is changed, the ws should close the connection (backend signal)
// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
@@ -270,7 +270,7 @@ test.describe('Doc Export', () => {
});
/**
* We cannot assert the line break is visible in the pdf, but we can assert the
* We cannot assert the line break is visible in the pdf but we can assert the
* line break is visible in the editor and that the pdf is generated.
*/
test('it exports the doc with divider', async ({ page, browserName }) => {
@@ -131,7 +131,7 @@ test.describe('Document list members', () => {
const list = page.getByTestId('doc-share-quick-search');
await expect(list).toBeVisible();
const currentUser = list.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
`doc-share-member-row-user@${browserName}.e2e`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await expect(currentUser).toBeVisible();
@@ -175,7 +175,7 @@ test.describe('Document list members', () => {
const list = page.getByTestId('doc-share-quick-search');
const emailMyself = `user@${browserName}.test`;
const emailMyself = `user@${browserName}.e2e`;
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
const mySelfMoreActions = mySelf.getByRole('button', {
name: 'more_horiz',
@@ -96,7 +96,7 @@ test.describe('Doc Routing', () => {
});
});
test.describe('Doc Routing: Not logged', () => {
test.describe('Doc Routing: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('checks redirect to a doc after login', async ({
@@ -151,7 +151,7 @@ test.describe('Doc Visibility: Restricted', () => {
});
const otherBrowser = browsersName.find((b) => b !== browserName);
const username = `user@${otherBrowser}.test`;
const username = `user@${otherBrowser}.e2e`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
@@ -295,7 +295,7 @@ test.describe('Doc Visibility: Public', () => {
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page.getByLabel('Editing').click();
await page.getByLabel('Edition').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -333,7 +333,7 @@ test.describe('Doc Visibility: Public', () => {
test.describe('Doc Visibility: Authenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A doc is not accessible when unauthenticated.', async ({
test('A doc is not accessible when unauthentified.', async ({
page,
browserName,
}) => {
@@ -476,7 +476,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
await page.getByLabel('Visibility mode').click();
await page.getByLabel('Editing').click();
await page.getByLabel('Edition').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -128,16 +128,8 @@ export async function waitForLanguageSwitch(
lang: TestLanguageValue,
) {
const header = page.locator('header').first();
const languagePicker = header.locator('.--docs--language-picker-text');
const isAlreadyTargetLanguage = await languagePicker
.innerText()
.then((text) => text.toLowerCase().includes(lang.label.toLowerCase()));
await header.getByRole('button', { name: 'arrow_drop_down' }).click();
if (isAlreadyTargetLanguage) {
return;
}
await languagePicker.click();
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes('/user') && resp.request().method() === 'PATCH',
@@ -7,14 +7,10 @@ server {
location / {
try_files $uri index.html $uri/ =404;
add_header X-Frame-Options DENY always;
}
location ~ "^/docs/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$" {
try_files $uri /docs/[id]/index.html;
add_header X-Frame-Options DENY always;
}
error_page 404 /404.html;
+6 -6
View File
@@ -30,8 +30,8 @@
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.1.0",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.26.0",
"@tanstack/react-query": "5.80.5",
"@sentry/nextjs": "9.22.0",
"@tanstack/react-query": "5.77.1",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -43,8 +43,8 @@
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.6.1",
"next": "15.3.3",
"posthog-js": "1.249.3",
"next": "15.3.2",
"posthog-js": "1.246.0",
"react": "*",
"react-aria-components": "1.9.0",
"react-dom": "*",
@@ -59,7 +59,7 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.80.5",
"@tanstack/react-query-devtools": "5.77.1",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
@@ -78,7 +78,7 @@
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.5.3",
"stylelint": "16.20.0",
"stylelint": "16.19.1",
"stylelint-config-standard": "38.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
@@ -29,7 +29,7 @@ describe('fetchAPI', () => {
});
});
it('check the versioning', () => {
it('check the versionning', () => {
fetchMock.mock('http://test.jest/api/v2.0/some/url', 200);
void fetchAPI('some/url', {}, '2.0');
@@ -1,48 +0,0 @@
import {
UseMutationResult,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/features/auth/api/types';
import { KEY_AUTH } from '@/features/auth/api/useAuthQuery';
type UserUpdateRequest = Partial<User>;
async function updateUser(userUpdateData: UserUpdateRequest): Promise<User> {
const response = await fetchAPI(`users/${userUpdateData.id}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userUpdateData),
});
if (!response.ok) {
throw new APIError(
`Failed to update the user`,
await errorCauses(response, userUpdateData),
);
}
return response.json() as Promise<User>;
}
export const useUserUpdate = (): UseMutationResult<
User,
APIError,
UserUpdateRequest
> => {
const queryClient = useQueryClient();
const mutationResult = useMutation<User, APIError, UserUpdateRequest>({
mutationFn: updateUser,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: [KEY_AUTH] });
},
onError: (error) => {
console.error('Error updating user', error);
},
});
return mutationResult;
};
@@ -1,15 +1,10 @@
import { Loader } from '@openfun/cunningham-react';
import Head from 'next/head';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useAuthQuery } from '@/features/auth';
import {
useCustomTranslations,
useSynchronizedLanguage,
} from '@/features/language';
import { useLanguageSynchronizer } from '@/features/language/';
import { useAnalytics } from '@/libs';
import { CrispProvider, PostHogAnalytic } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
@@ -18,35 +13,10 @@ import { useConfig } from './api/useConfig';
export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { data: conf } = useConfig();
const { data: user } = useAuthQuery();
const { setSentry } = useSentryStore();
const { setTheme } = useCunninghamTheme();
const { changeLanguageSynchronized } = useSynchronizedLanguage();
const { customizeTranslations } = useCustomTranslations();
const { AnalyticsProvider } = useAnalytics();
const { i18n } = useTranslation();
const languageSynchronized = useRef(false);
useEffect(() => {
if (!user || languageSynchronized.current) {
return;
}
const targetLanguage =
user?.language ?? i18n.resolvedLanguage ?? i18n.language;
void changeLanguageSynchronized(targetLanguage, user).then(() => {
languageSynchronized.current = true;
});
}, [user, i18n.resolvedLanguage, i18n.language, changeLanguageSynchronized]);
useEffect(() => {
if (!conf?.theme_customization?.translations) {
return;
}
customizeTranslations(conf.theme_customization.translations);
}, [conf?.theme_customization?.translations, customizeTranslations]);
const { synchronizeLanguage } = useLanguageSynchronizer();
useEffect(() => {
if (!conf?.SENTRY_DSN) {
@@ -64,6 +34,10 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
setTheme(conf.FRONTEND_THEME);
}, [conf?.FRONTEND_THEME, setTheme]);
useEffect(() => {
void synchronizeLanguage();
}, [synchronizeLanguage]);
useEffect(() => {
if (!conf?.POSTHOG_KEY) {
return;
@@ -1,5 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { Resource } from 'i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
@@ -8,10 +7,9 @@ import { PostHogConf } from '@/services';
interface ThemeCustomization {
footer?: FooterType;
translations?: Resource;
}
export interface ConfigResponse {
interface ConfigResponse {
AI_FEATURE_ENABLED?: boolean;
COLLABORATION_WS_URL?: string;
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean;
@@ -11,5 +11,5 @@ export interface User {
email: string;
full_name: string;
short_name: string;
language?: string;
language: string;
}
@@ -178,6 +178,7 @@ export const BlockNoteEditorVersion = ({
initialContent,
}: BlockNoteEditorVersionProps) => {
const readOnly = true;
const { setEditor } = useEditorStore();
const editor = useCreateBlockNote(
{
collaboration: {
@@ -192,6 +193,15 @@ export const BlockNoteEditorVersion = ({
},
[initialContent],
);
useHeadings(editor);
useEffect(() => {
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
return (
<Box $css={cssEditor(readOnly)} className="--docs--editor-container">
@@ -5,6 +5,7 @@ import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader, DocVersionHeader } from '@/docs/doc-header/';
import {
Doc,
@@ -25,6 +26,9 @@ interface DocEditorProps {
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
const { isDesktop } = useResponsiveStore();
const isVersion = !!versionId && typeof versionId === 'string';
const { colorsTokens } = useCunninghamTheme();
const { provider } = useProviderStore();
if (!provider) {
@@ -62,6 +66,7 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
</Box>
<Box
$background={colorsTokens['primary-bg']}
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
@@ -1,4 +1,4 @@
import { EmojiMartData } from '@emoji-mart/data';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
@@ -6,15 +6,19 @@ import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
interface EmojiPickerProps {
emojiData: EmojiMartData;
categories: string[];
custom: {
name: string;
id: string;
emojis: string[];
}[];
onClickOutside: () => void;
onEmojiSelect: ({ native }: { native: string }) => void;
}
export const EmojiPicker = ({
emojiData,
categories,
custom,
onClickOutside,
onEmojiSelect,
}: EmojiPickerProps) => {
@@ -23,8 +27,9 @@ export const EmojiPicker = ({
return (
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
<Picker
data={emojiData}
categories={categories}
custom={custom}
data={data}
locale={i18n.resolvedLanguage}
navPosition="none"
onClickOutside={onClickOutside}
@@ -10,7 +10,52 @@ import { Box, BoxButton, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import { EmojiPicker } from '../EmojiPicker';
import InitEmojiCallout from './initEmojiCallout';
const calloutCustom = [
{
name: 'Callout',
id: 'callout',
emojis: [
'bulb',
'point_right',
'point_up',
'ok_hand',
'key',
'construction',
'warning',
'fire',
'pushpin',
'scissors',
'question',
'no_entry',
'no_entry_sign',
'alarm_clock',
'phone',
'rotating_light',
'recycle',
'white_check_mark',
'lock',
'paperclip',
'book',
'speaking_head_in_silhouette',
'arrow_right',
'loudspeaker',
'hammer_and_wrench',
'gear',
],
},
];
const calloutCategories = [
'callout',
'people',
'nature',
'foods',
'activity',
'places',
'flags',
'objects',
'symbols',
];
export const CalloutBlock = createReactBlockSpec(
{
@@ -79,8 +124,8 @@ export const CalloutBlock = createReactBlockSpec(
{openEmojiPicker && (
<EmojiPicker
emojiData={InitEmojiCallout.emojidata}
categories={InitEmojiCallout.calloutCategories}
categories={calloutCategories}
custom={calloutCustom}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
/>
@@ -1,76 +0,0 @@
/**
* "emoji-mart" is a singleton, multiple imports in the same
* application could cause issues.
* BlockNote uses "emoji-mart" internally as well, if
* Blocknote emoji picker is init before the callout emoji picker,
* the callout emoji picker will not be set up correctly.
* To avoid this, we initialize emoji-mart here and before any
* other components that uses it.
*/
import data, { Category, EmojiMartData } from '@emoji-mart/data';
import { init } from 'emoji-mart';
type EmojiMartDataFixed = Omit<EmojiMartData, 'categories'> & {
categories: (Category & { name: string })[];
};
const emojidata = structuredClone(data) as EmojiMartDataFixed;
const CALLOUT_ID = 'callout';
const CALLOUT_EMOJIS = [
'bulb',
'point_right',
'point_up',
'ok_hand',
'key',
'construction',
'warning',
'fire',
'pushpin',
'scissors',
'question',
'no_entry',
'no_entry_sign',
'alarm_clock',
'phone',
'rotating_light',
'recycle',
'white_check_mark',
'lock',
'paperclip',
'book',
'speaking_head_in_silhouette',
'arrow_right',
'loudspeaker',
'hammer_and_wrench',
'gear',
];
if (!emojidata.categories.some((c) => c.id === CALLOUT_ID)) {
emojidata.categories.unshift({
id: CALLOUT_ID,
name: 'Callout',
emojis: CALLOUT_EMOJIS,
});
}
void init({ data: emojidata });
const calloutCategories = [
'callout',
'people',
'nature',
'foods',
'activity',
'places',
'flags',
'objects',
'symbols',
];
const calloutEmojiData = {
emojidata,
calloutCategories,
};
export default calloutEmojiData;
@@ -9,7 +9,7 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
useEffect(() => {
setHeadings(editor);
editor?.onChange(() => {
editor?.onEditorContentChange(() => {
setHeadings(editor);
});
@@ -5,7 +5,6 @@ export const cssEditor = (readonly: boolean) => css`
& > .bn-container,
& .ProseMirror {
height: 100%;
padding-bottom: 2rem;
img.bn-visual-media[src*='-unsafe'] {
pointer-events: none;
@@ -73,7 +72,8 @@ export const cssEditor = (readonly: boolean) => css`
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block[data-background-color] > .bn-block-content {
.bn-block-content[data-content-type='paragraph'],
.bn-block-content[data-content-type='heading'] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
}
@@ -5,7 +5,7 @@ import { DocsExporterPDF } from '../types';
export const blockMappingParagraphPDF: DocsExporterPDF['mappings']['blockMapping']['paragraph'] =
(block, exporter) => {
/**
* Break line in the editor are not rendered in the PDF
* Breakline in the editor are not rendered in the PDF
* By adding a space if the block is empty we ensure that the block is rendered
*/
if (Array.isArray(block.content)) {
@@ -34,12 +34,12 @@ export const useRemoveDoc = (options?: UseRemoveDocOptions) => {
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
options.onError(error, variables, context);
}
},
});
@@ -63,12 +63,12 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
queryKey: [KEY_LIST_USER],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
options.onError(error, variables, context);
}
},
});
@@ -58,12 +58,12 @@ export const useDeleteDocInvitation = (
queryKey: [KEY_LIST_DOC_INVITATIONS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
options.onError(error, variables, context);
}
},
});
@@ -65,12 +65,12 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
options.onError(error, variables, context);
}
},
});
@@ -66,12 +66,12 @@ export const useUpdateDocInvitation = (
queryKey: [KEY_LIST_DOC_INVITATIONS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
options.onError(error, variables, context);
}
},
});
@@ -13,7 +13,7 @@ export const useTranslatedShareSettings = () => {
const linkModeTranslations = {
[LinkRole.READER]: t('Reading'),
[LinkRole.EDITOR]: t('Editing'),
[LinkRole.EDITOR]: t('Edition'),
};
const linkReachChoices = {
@@ -40,7 +40,7 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$align="center"
role="row"
$gap="20px"
$padding={{ vertical: '4xs', horizontal: isDesktop ? 'base' : 'xs' }}
$padding={{ vertical: '2xs', horizontal: isDesktop ? 'base' : 'xs' }}
$css={css`
cursor: pointer;
border-radius: 4px;
@@ -50,7 +50,6 @@ export const SimpleDocItem = ({
background-color: transparent;
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
`}
$padding={`${spacingsTokens['3xs']} 0`}
>
{isPinned ? (
<PinnedDocumentIcon
@@ -1,3 +1,5 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -16,6 +18,7 @@ import { Title } from './Title';
export const Header = () => {
const { t } = useTranslation();
const router = useRouter();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
@@ -63,6 +66,13 @@ export const Header = () => {
) : (
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
<ButtonLogin />
<Button
onClick={() => router.push(`/user-tokens`)}
aria-label={t('API Tokens', 'API Tokens')}
color="primary-text"
>
{t('API Tokens', 'API Tokens')}
</Button>
<LanguagePicker />
<LaGaufre />
</Box>
@@ -1,33 +1,42 @@
import { Settings } from 'luxon';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, Icon, Text } from '@/components/';
import { useConfig } from '@/core';
import { useAuthQuery } from '@/features/auth';
import {
getMatchingLocales,
useSynchronizedLanguage,
} from '@/features/language';
import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer';
import { getMatchingLocales } from './utils/locale';
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { data: conf } = useConfig();
const { data: user } = useAuthQuery();
const { changeLanguageSynchronized } = useSynchronizedLanguage();
const language = i18n.language;
const { synchronizeLanguage } = useLanguageSynchronizer();
const language = i18n.languages[0];
Settings.defaultLocale = language;
// Compute options for dropdown
const optionsPicker = useMemo(() => {
const backendOptions = conf?.LANGUAGES ?? [[language, language]];
return backendOptions.map(([backendLocale, backendLabel]) => {
return {
label: backendLabel,
isSelected: getMatchingLocales([backendLocale], [language]).length > 0,
callback: () => changeLanguageSynchronized(backendLocale, user),
return backendOptions.map(([backendLocale, label]) => {
// Determine if the option is selected
const isSelected =
getMatchingLocales([backendLocale], [language]).length > 0;
// Define callback for updating both frontend and backend languages
const callback = () => {
i18n
.changeLanguage(backendLocale)
.then(() => {
void synchronizeLanguage('toBackend');
})
.catch((err) => {
console.error('Error changing language', err);
});
};
return { label, isSelected, callback };
});
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]);
}, [conf, i18n, language, synchronizeLanguage]);
// Extract current language label for display
const currentLanguageLabel =
@@ -0,0 +1,45 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/features/auth/api/types';
export interface ChangeUserLanguageParams {
userId: User['id'];
language: User['language'];
}
export const changeUserLanguage = async ({
userId,
language,
}: ChangeUserLanguageParams): Promise<User> => {
const response = await fetchAPI(`users/${userId}/`, {
method: 'PATCH',
body: JSON.stringify({
language,
}),
});
if (!response.ok) {
throw new APIError(
`Failed to change the user language to ${language}`,
await errorCauses(response, {
value: language,
type: 'language',
}),
);
}
return response.json() as Promise<User>;
};
export function useChangeUserLanguage() {
const queryClient = useQueryClient();
return useMutation<User, APIError, ChangeUserLanguageParams>({
mutationFn: changeUserLanguage,
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['change-user-language'],
});
},
});
}
@@ -1 +0,0 @@
export * from './LanguagePicker';
@@ -1,2 +0,0 @@
export * from './useSynchronizedLanguage';
export * from './useCustomTranslations';
@@ -1,27 +0,0 @@
import { Resource } from 'i18next';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const useCustomTranslations = () => {
const { i18n } = useTranslation();
// Overwrite translations with a resource
const customizeTranslations = useCallback(
(currentCustomTranslations: Resource) => {
Object.entries(currentCustomTranslations).forEach(([lng, namespaces]) => {
Object.entries(namespaces).forEach(([ns, value]) => {
i18n.addResourceBundle(lng, ns, value, true, true);
});
});
// trigger re-render
if (Object.entries(currentCustomTranslations).length > 0) {
void i18n.changeLanguage(i18n.language);
}
},
[i18n],
);
return {
customizeTranslations,
};
};
@@ -0,0 +1,82 @@
import { useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useConfig } from '@/core';
import { useAuthQuery } from '@/features/auth/api';
import { useChangeUserLanguage } from '@/features/language/api/useChangeUserLanguage';
import { getMatchingLocales } from '@/features/language/utils/locale';
import { availableFrontendLanguages } from '@/i18n/initI18n';
export const useLanguageSynchronizer = () => {
const { data: conf, isSuccess: confInitialized } = useConfig();
const { data: user, isSuccess: userInitialized } = useAuthQuery();
const { i18n } = useTranslation();
const { mutateAsync: changeUserLanguage } = useChangeUserLanguage();
const languageSynchronizing = useRef(false);
const availableBackendLanguages = useMemo(() => {
return conf?.LANGUAGES.map(([locale]) => locale);
}, [conf?.LANGUAGES]);
const synchronizeLanguage = useCallback(
async (direction?: 'toBackend' | 'toFrontend') => {
if (
languageSynchronizing.current ||
!userInitialized ||
!confInitialized ||
!availableBackendLanguages ||
!availableFrontendLanguages
) {
return;
}
languageSynchronizing.current = true;
try {
const userPreferredLanguages = user.language ? [user.language] : [];
const setOrDetectedLanguages = i18n.languages;
// Default direction depends on whether a user already has a language preference
direction =
direction ??
(userPreferredLanguages.length ? 'toFrontend' : 'toBackend');
if (direction === 'toBackend') {
// Update user's preference from frontends's language
const closestBackendLanguage =
getMatchingLocales(
availableBackendLanguages,
setOrDetectedLanguages,
)[0] || availableBackendLanguages[0];
await changeUserLanguage({
userId: user.id,
language: closestBackendLanguage,
});
} else {
// Update frontends's language from user's preference
const closestFrontendLanguage =
getMatchingLocales(
availableFrontendLanguages,
userPreferredLanguages,
)[0] || availableFrontendLanguages[0];
if (i18n.resolvedLanguage !== closestFrontendLanguage) {
await i18n.changeLanguage(closestFrontendLanguage);
}
}
} catch (error) {
console.error('Error synchronizing language', error);
} finally {
languageSynchronizing.current = false;
}
},
[
i18n,
user,
userInitialized,
confInitialized,
availableBackendLanguages,
changeUserLanguage,
],
);
return { synchronizeLanguage };
};
@@ -1,71 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserUpdate } from '@/core/api/useUserUpdate';
import { useConfig } from '@/core/config/api/useConfig';
import { User } from '@/features/auth';
import { getMatchingLocales } from '@/features/language/utils/locale';
export const useSynchronizedLanguage = () => {
const { i18n } = useTranslation();
const { mutateAsync: updateUser } = useUserUpdate();
const { data: config } = useConfig();
const isSynchronizingLanguage = useRef(false);
const availableFrontendLanguages = useMemo(
() => Object.keys(i18n?.options?.resources || { en: '<- fallback' }),
[i18n?.options?.resources],
);
const availableBackendLanguages = useMemo(
() => config?.LANGUAGES?.map(([locale]) => locale) || [],
[config?.LANGUAGES],
);
const changeBackendLanguage = useCallback(
async (language: string, user?: User) => {
const closestBackendLanguage = getMatchingLocales(
availableBackendLanguages,
[language],
)[0];
if (user && user.language !== closestBackendLanguage) {
await updateUser({ id: user.id, language: closestBackendLanguage });
}
},
[availableBackendLanguages, updateUser],
);
const changeFrontendLanguage = useCallback(
async (language: string) => {
const closestFrontendLanguage = getMatchingLocales(
availableFrontendLanguages,
[language],
)[0];
if (
i18n.isInitialized &&
i18n.resolvedLanguage !== closestFrontendLanguage
) {
await i18n.changeLanguage(closestFrontendLanguage);
}
},
[availableFrontendLanguages, i18n],
);
const changeLanguageSynchronized = useCallback(
async (language: string, user?: User) => {
if (!isSynchronizingLanguage.current) {
isSynchronizingLanguage.current = true;
await changeFrontendLanguage(language);
await changeBackendLanguage(language, user);
isSynchronizingLanguage.current = false;
}
},
[changeBackendLanguage, changeFrontendLanguage],
);
return {
changeLanguageSynchronized,
changeFrontendLanguage,
changeBackendLanguage,
};
};
@@ -1,3 +1,2 @@
export * from './hooks';
export * from './components';
export * from './utils';
export * from './hooks/useLanguageSynchronizer';
export * from './LanguagePicker';
@@ -1 +0,0 @@
export * from './locale';
@@ -112,7 +112,7 @@ export class ApiPlugin implements WorkboxPlugin {
};
/**
* When we get a network error.
* When we get an network error.
*/
handlerDidError: WorkboxPlugin['handlerDidError'] = async ({ request }) => {
if (!this.isFetchDidFailed) {
@@ -0,0 +1,3 @@
export * from './useListUserTokens';
export * from './useCreateUserToken';
export * from './useDeleteUserToken';
@@ -0,0 +1,28 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { NewUserToken } from '../types';
export const createUserToken = async (): Promise<NewUserToken> => {
const response = await fetchAPI(`user-tokens/`, {
method: 'POST',
// The backend test indicates no data is sent for creation, so body is an empty object
body: JSON.stringify({}),
});
if (!response.ok) {
throw new APIError(
'Failed to create user token',
await errorCauses(response),
);
}
return response.json() as Promise<NewUserToken>;
};
export function useCreateUserToken() {
return useMutation<NewUserToken, APIError>({
mutationFn: createUserToken,
});
}
@@ -0,0 +1,30 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
export const deleteUserToken = async (digest: string): Promise<void> => {
const response = await fetchAPI(`user-tokens/${digest}/`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 204) {
// 204 is a valid response for delete
throw new APIError(
'Failed to delete user token',
await errorCauses(response),
);
}
// For 204, there's no content, and for other successful deletions, we don't expect content.
// So, we don't try to parse JSON.
return Promise.resolve();
};
export type DeleteUserTokenParams = {
digest: string;
};
export function useDeleteUserToken() {
return useMutation<void, APIError, DeleteUserTokenParams>({
mutationFn: ({ digest }) => deleteUserToken(digest),
});
}
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { UserToken } from '../types';
export const listUserTokens = async (): Promise<UserToken[]> => {
const response = await fetchAPI(`user-tokens/`);
if (!response.ok) {
throw new APIError(
'Failed to list user tokens',
await errorCauses(response),
);
}
return response.json() as Promise<UserToken[]>;
};
export function useListUserTokens() {
return useQuery<UserToken[], APIError>({
queryKey: ['userTokens'],
queryFn: listUserTokens,
});
}
@@ -0,0 +1,314 @@
import {
Button as CunninghamButton,
DataGrid,
Modal,
ModalSize,
} from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Card } from '@/components';
import { createUserToken, deleteUserToken, listUserTokens } from '../api/index';
import { NewUserToken, UserToken } from '../types';
const formatTimeAgo = (dateString: string) => {
const now = new Date();
const date = new Date(dateString);
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (seconds < 60) {
return `${seconds} seconds ago`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes} minutes ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours} hours ago`;
}
const days = Math.floor(hours / 24);
return `${days} days ago`;
};
// Add id to UserToken type for DataGrid compatibility
interface UserTokenWithId extends UserToken {
id: string;
}
// Define proper type for DataGrid columns
interface ColumnDef {
field: string;
headerName: string;
width?: number;
renderCell: (params: { row: UserTokenWithId }) => React.ReactNode;
}
export const UserTokenManager: React.FC = () => {
const [tokens, setTokens] = useState<UserTokenWithId[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [newToken, setNewToken] = useState<NewUserToken | null>(null);
const [modalState, setModalState] = useState<{
isOpen: boolean;
title: string;
message: React.ReactNode;
onConfirm?: () => void;
confirmText?: string;
isConfirmation: boolean;
type?: 'success' | 'error' | 'warning' | 'info';
size: ModalSize;
}>({
isOpen: false,
title: '',
message: '',
isConfirmation: false,
size: ModalSize.MEDIUM, // Default size using ModalSize enum
});
const showNotification = (
message: string,
type: 'success' | 'error' = 'success',
size: ModalSize = ModalSize.SMALL,
) => {
setModalState({
isOpen: true,
title: type === 'success' ? 'Success' : 'Error',
message,
isConfirmation: false,
type: type,
size,
});
};
const showConfirmation = (
title: string,
message: React.ReactNode,
onConfirm: () => void,
confirmText: string = 'Confirm',
size: ModalSize = ModalSize.MEDIUM,
) => {
setModalState({
isOpen: true,
title,
message,
onConfirm,
confirmText,
isConfirmation: true,
size,
});
};
const fetchTokens = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const fetchedTokens = await listUserTokens();
// Add id to each token
setTokens(fetchedTokens.map((token) => ({ ...token, id: token.digest })));
} catch (err) {
setError(
'Failed to fetch tokens. Please ensure you are logged in and have permissions.',
);
showNotification('Failed to fetch tokens', 'error');
console.error(err);
}
setIsLoading(false);
}, []);
useEffect(() => {
void fetchTokens();
}, [fetchTokens]);
const handleCreateToken = async () => {
setIsLoading(true);
setError(null);
setNewToken(null);
try {
const generatedToken = await createUserToken();
setNewToken(generatedToken);
showNotification(
'Token created successfully! Store the token key safely, it will not be shown again.',
'success',
ModalSize.LARGE,
);
void fetchTokens();
} catch (err) {
setError('Failed to create token.');
showNotification('Failed to create token', 'error');
console.error(err);
}
setIsLoading(false);
};
const handleDeleteToken = (digest: string) => {
showConfirmation(
'Confirm Deletion',
'Are you sure you want to delete this token?',
() => {
void (async () => {
setIsLoading(true);
setError(null);
try {
await deleteUserToken(digest);
showNotification('Token deleted successfully!');
setNewToken(null);
await fetchTokens();
} catch (err) {
setError('Failed to delete token.');
showNotification('Failed to delete token', 'error');
console.error(err);
}
setIsLoading(false);
})();
},
);
};
const columns: ColumnDef[] = [
{
field: 'digest',
headerName: 'Name',
renderCell: ({ row }: { row: UserTokenWithId }) => <>{row.digest}</>,
},
{
field: 'created',
headerName: 'Updated at',
renderCell: ({ row }: { row: UserTokenWithId }) => (
<>{formatTimeAgo(row.created)}</>
),
},
{
field: 'expires',
headerName: 'Expires at',
renderCell: ({ row }: { row: UserTokenWithId }) => <>{row.expiry}</>,
},
{
field: 'actions',
headerName: '',
width: 50,
renderCell: ({ row }: { row: UserTokenWithId }) => (
<CunninghamButton
onClick={() => {
handleDeleteToken(row.digest);
}}
color="danger"
size="small"
icon={<span className="material-icons">delete</span>}
aria-label="Delete token"
>
Delete
</CunninghamButton>
),
},
];
return (
<Card
$direction="column"
$width="100%"
$padding={{
top: 'base',
horizontal: 'md',
bottom: 'md',
}}
>
<Box $direction="row" $justify="space-between" $align="center">
<h2 style={{ marginBottom: 'var(--c--theme--spacing--medium, 16px)' }}>
User token management
</h2>
<CunninghamButton
onClick={() => void handleCreateToken()}
disabled={isLoading}
>
{isLoading ? 'Generating...' : 'Generate New Token'}
</CunninghamButton>
</Box>
{newToken && (
<Box
$background="var(--c--theme--colors--success-100)"
$padding="md"
$radius="10px"
$margin={{ bottom: 'var(--c--theme--spacing--medium, 16px)' }}
$direction="column"
>
<span style={{ marginLeft: 16 }}>
<strong>New Token:</strong> <code>{newToken.token_key}</code>
</span>
<span style={{ marginLeft: 16 }}>
<strong>Digest:</strong> <code>{newToken.digest}</code>
</span>
<span style={{ marginLeft: 16 }}>
<strong>Expires:</strong>{' '}
<code>{new Date(newToken.expiry).toLocaleString()}</code>
</span>
</Box>
)}
{isLoading && !tokens.length && (
<Box $margin={{ bottom: 'var(--c--theme--spacing--small, 8px)' }}>
Loading...
</Box>
)}
{error && (
<Box
$color="var(--c--theme--colors--danger-500, red)"
$margin={{ bottom: 'var(--c--theme--spacing--small, 8px)' }}
>
{error}
</Box>
)}
<DataGrid<UserTokenWithId>
rows={tokens}
columns={columns}
isLoading={isLoading}
emptyCta={<div>No tokens found.</div>}
/>
{modalState.isOpen && (
<Modal
isOpen={modalState.isOpen}
onClose={() => setModalState((prev) => ({ ...prev, isOpen: false }))}
title={modalState.title}
size={modalState.size} // Use ModalSize enum directly
actions={
modalState.isConfirmation ? (
<Box $width="100%" $direction="row" $justify="space-between">
<CunninghamButton
onClick={() =>
setModalState((prev) => ({ ...prev, isOpen: false }))
}
color="secondary"
>
Cancel
</CunninghamButton>
<CunninghamButton
onClick={() => {
if (modalState.onConfirm) {
modalState.onConfirm();
}
setModalState((prev) => ({ ...prev, isOpen: false }));
}}
color="danger"
>
{modalState.confirmText || 'Confirm'}
</CunninghamButton>
</Box>
) : (
<CunninghamButton
onClick={() =>
setModalState((prev) => ({ ...prev, isOpen: false }))
}
color="primary"
>
Close
</CunninghamButton>
)
}
>
{modalState.message}
</Modal>
)}
</Card>
);
};
@@ -0,0 +1 @@
export { UserTokenManager } from './components/UserTokenManager';
@@ -0,0 +1,9 @@
export interface UserToken {
digest: string;
created: string; // Assuming ISO date string
expiry: string; // Assuming ISO date string
}
export interface NewUserToken extends UserToken {
token_key: string;
}
+29 -31
View File
@@ -4,38 +4,36 @@ import { initReactI18next } from 'react-i18next';
import resources from './translations.json';
// Add an initialization guard
let isInitialized = false;
export const availableFrontendLanguages: readonly string[] =
Object.keys(resources);
// Initialize i18next with the base translations only once
if (!isInitialized && !i18next.isInitialized) {
isInitialized = true;
i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
debug: false,
detection: {
order: ['cookie', 'navigator'],
caches: ['cookie'],
lookupCookie: 'docs_language',
cookieMinutes: 525600,
cookieOptions: {
path: '/',
sameSite: 'lax',
},
i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
debug: false,
detection: {
order: ['cookie', 'navigator'], // detection order
caches: ['cookie'], // Use cookies to store the language preference
lookupCookie: 'docs_language',
cookieMinutes: 525600, // Expires after one year
cookieOptions: {
path: '/',
sameSite: 'lax',
},
interpolation: {
escapeValue: false,
},
lowerCaseLng: true,
nsSeparator: false,
keySeparator: false,
})
.catch((e) => console.error('i18n initialization failed:', e));
}
},
interpolation: {
escapeValue: false,
},
preload: availableFrontendLanguages,
lowerCaseLng: true,
nsSeparator: false,
keySeparator: false,
})
.catch(() => {
throw new Error('i18n initialization failed');
});
export default i18next;
+25 -38
View File
@@ -1,5 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import Head from 'next/head';
import Image from 'next/image';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
@@ -18,46 +17,34 @@ const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<>
<Head>
<title>
{t('Access Denied - Error 403')} - {t('Docs')}
</title>
<meta
property="og:title"
content={`${t('Access Denied - Error 403')} - ${t('Docs')}`}
key="title"
/>
</Head>
<Box
$align="center"
$margin="auto"
$gap="1rem"
$padding={{ bottom: '2rem' }}
>
<Image
className="c__image-system-filter"
src={img403}
alt={t('Image 403')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Box
$align="center"
$margin="auto"
$gap="1rem"
$padding={{ bottom: '2rem' }}
>
<Image
className="c__image-system-filter"
src={img403}
alt={t('Image 403')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
{t('You do not have permission to view this document.')}
</Text>
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
{t('You do not have permission to view this document.')}
</Text>
<StyledLink href="/">
<StyledButton icon={<Icon iconName="house" $color="white" />}>
{t('Home')}
</StyledButton>
</StyledLink>
</Box>
<StyledLink href="/">
<StyledButton icon={<Icon iconName="house" $color="white" />}>
{t('Home')}
</StyledButton>
</StyledLink>
</Box>
</>
</Box>
);
};
+27 -40
View File
@@ -1,5 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import Head from 'next/head';
import Image from 'next/image';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
@@ -18,48 +17,36 @@ const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<>
<Head>
<title>
{t('Page Not Found - Error 404')} - {t('Docs')}
</title>
<meta
property="og:title"
content={`${t('Page Not Found - Error 404')} - ${t('Docs')}`}
key="title"
/>
</Head>
<Box
$align="center"
$margin="auto"
$gap="1rem"
$padding={{ bottom: '2rem' }}
>
<Image
className="c__image-system-filter"
src={img403}
alt={t('Image 403')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Box
$align="center"
$margin="auto"
$gap="1rem"
$padding={{ bottom: '2rem' }}
>
<Image
className="c__image-system-filter"
src={img403}
alt={t('Image 403')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
{t(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
)}
</Text>
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
{t(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
)}
</Text>
<StyledLink href="/">
<StyledButton icon={<Icon iconName="house" $color="white" />}>
{t('Home')}
</StyledButton>
</StyledLink>
</Box>
<StyledLink href="/">
<StyledButton icon={<Icon iconName="house" $color="white" />}>
{t('Home')}
</StyledButton>
</StyledLink>
</Box>
</>
</Box>
);
};
@@ -26,7 +26,6 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
<>
<Head>
<title>{t('Docs')}</title>
<meta property="og:title" content={t('Docs')} key="title" />
<meta
name="description"
content={t(

Some files were not shown because too many files have changed in this diff Show More