Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cb7aeb7ec | |||
| 23860065e1 | |||
| f459c56121 | |||
| 4a81e1526e | |||
| abcd61cf2f | |||
| c1a591fb4f | |||
| 83d8478b5d | |||
| 6bd136c76e | |||
| e929fcc682 | |||
| fa819bc1ff | |||
| 43e529da2a |
@@ -34,4 +34,3 @@ db.sqlite3
|
||||
|
||||
# Frontend
|
||||
node_modules
|
||||
.next
|
||||
|
||||
@@ -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
|
||||
@@ -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...
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: '22.x'
|
||||
default: '20.x'
|
||||
type: string
|
||||
with-front-dependencies-installation:
|
||||
type: boolean
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 1–2 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.
|
||||
@@ -53,18 +53,4 @@ Below is a visual example of a configured footer ⬇️:
|
||||
|
||||

|
||||
|
||||
----
|
||||
|
||||
# **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
|
||||
@@ -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.
|
||||
@@ -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,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
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"", ""])
|
||||
|
||||
# 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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
+48
-3
@@ -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}
|
||||
/>
|
||||
|
||||
-76
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+23
-14
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user