mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-07 07:32:33 +02:00
Compare commits
71 Commits
e2ee-hacka
...
install-p-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae1254dc68 | ||
|
|
5342eb49c7 | ||
|
|
97d00b678f | ||
|
|
52eb973164 | ||
|
|
789879a9cc | ||
|
|
52c52d53b7 | ||
|
|
54fe6a2319 | ||
|
|
bc5dcb0ed5 | ||
|
|
6c3f3f6a77 | ||
|
|
6e64bad1e2 | ||
|
|
0d5b2382ab | ||
|
|
39d0211593 | ||
|
|
86085f87a1 | ||
|
|
ebdcb4b2f0 | ||
|
|
3a0dff5b0e | ||
|
|
c682bce6f6 | ||
|
|
8dd7671d1f | ||
|
|
fe391523c8 | ||
|
|
399cf893ad | ||
|
|
f081f7826a | ||
|
|
638e1aedb7 | ||
|
|
dcbef9630e | ||
|
|
a745cb7498 | ||
|
|
d701195ae5 | ||
|
|
ac18d23fbc | ||
|
|
ff7914f6d3 | ||
|
|
647e6c1cf5 | ||
|
|
98b60ebe93 | ||
|
|
0b15ebba71 | ||
|
|
eee20033ae | ||
|
|
e642506675 | ||
|
|
883055b5fb | ||
|
|
968a1383f7 | ||
|
|
6a2030e235 | ||
|
|
4d2a73556a | ||
|
|
90027d3a5a | ||
|
|
61593bd807 | ||
|
|
99ebc9fc9c | ||
|
|
a5e798164c | ||
|
|
002b9340e3 | ||
|
|
f00f833ee2 | ||
|
|
3a6bc8c0f7 | ||
|
|
76368f1ae9 | ||
|
|
fab86f7f87 | ||
|
|
ac74db2fde | ||
|
|
b2480eea74 | ||
|
|
20a898c978 | ||
|
|
589d3abd8d | ||
|
|
1ba588d416 | ||
|
|
b1f37495d6 | ||
|
|
8c9cb43097 | ||
|
|
aeeed8feb5 | ||
|
|
1e89eb1a21 | ||
|
|
413e0bebad | ||
|
|
a2a184bb93 | ||
|
|
827d8cc8e1 | ||
|
|
833c53f5aa | ||
|
|
2775a74bdb | ||
|
|
450790366d | ||
|
|
7b04f664cd | ||
|
|
358508ffa3 | ||
|
|
9388c8f8f4 | ||
|
|
40d8c949d9 | ||
|
|
6b0b052d78 | ||
|
|
ac86a4e7f7 | ||
|
|
bbe5501297 | ||
|
|
b37acf3138 | ||
|
|
5bd78b8068 | ||
|
|
ed39c01608 | ||
|
|
748ebc8f26 | ||
|
|
03262878c4 |
25
.github/workflows/docker-hub.yml
vendored
25
.github/workflows/docker-hub.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: Docker Hub Workflow
|
||||
run-name: Docker Hub Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -48,9 +49,15 @@ jobs:
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
@@ -92,9 +99,15 @@ jobs:
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
@@ -137,9 +150,15 @@ jobs:
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
|
||||
22
.github/workflows/helmfile-linter.yaml
vendored
Normal file
22
.github/workflows/helmfile-linter.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
-
|
||||
uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "impress,secrets"
|
||||
126
.github/workflows/impress-frontend.yml
vendored
126
.github/workflows/impress-frontend.yml
vendored
@@ -39,29 +39,6 @@ jobs:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
build-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Build CI App
|
||||
run: cd src/frontend/ && yarn ci:build
|
||||
|
||||
- name: Cache build frontend
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: src/frontend/apps/impress/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
@@ -98,25 +75,11 @@ jobs:
|
||||
|
||||
test-e2e-chromium:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-front
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
make data/media
|
||||
make create-env-files
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
@@ -124,42 +87,11 @@ jobs:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Restore the build cache
|
||||
uses: actions/cache@v4
|
||||
id: cache-build
|
||||
with:
|
||||
path: src/frontend/apps/impress/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build the Docker images
|
||||
uses: docker/bake-action@v4
|
||||
with:
|
||||
targets: |
|
||||
app-dev
|
||||
y-provider
|
||||
load: true
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=cached-stage
|
||||
*.cache-to=type=gha,scope=cached-stage,mode=max
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
|
||||
- name: Start Docker services
|
||||
run: |
|
||||
make run
|
||||
|
||||
- name: Start Nginx for the frontend
|
||||
run: |
|
||||
docker compose up --force-recreate -d nginx-front
|
||||
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
|
||||
- name: Add dummy data
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
|
||||
@@ -176,25 +108,12 @@ jobs:
|
||||
|
||||
test-e2e-other-browser:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-front
|
||||
needs: test-e2e-chromium
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
make data/media
|
||||
make create-env-files
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
@@ -202,42 +121,11 @@ jobs:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Restore the build cache
|
||||
uses: actions/cache@v4
|
||||
id: cache-build
|
||||
with:
|
||||
path: src/frontend/apps/impress/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build the Docker images
|
||||
uses: docker/bake-action@v4
|
||||
with:
|
||||
targets: |
|
||||
app-dev
|
||||
y-provider
|
||||
load: true
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=cached-stage
|
||||
*.cache-to=type=gha,scope=cached-stage,mode=max
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
|
||||
- name: Start Docker services
|
||||
run: |
|
||||
make run
|
||||
|
||||
- name: Start Nginx for the frontend
|
||||
run: |
|
||||
docker compose up --force-recreate -d nginx-front
|
||||
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
|
||||
- name: Add dummy data
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
|
||||
|
||||
287
.github/workflows/impress.yml
vendored
287
.github/workflows/impress.yml
vendored
@@ -9,52 +9,52 @@ on:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
# lint-git:
|
||||
# runs-on: ubuntu-latest
|
||||
# if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@v2
|
||||
# with:
|
||||
# fetch-depth: 0
|
||||
# - name: show
|
||||
# run: git log
|
||||
# - name: Enforce absence of print statements in code
|
||||
# run: |
|
||||
# ! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
|
||||
# - name: Check absence of fixup commits
|
||||
# run: |
|
||||
# ! git log | grep 'fixup!'
|
||||
# - name: Install gitlint
|
||||
# run: pip install --user requests gitlint
|
||||
# - name: Lint commit messages added to main
|
||||
# run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
lint-git:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install gitlint
|
||||
run: pip install --user requests gitlint
|
||||
- name: Lint commit messages added to main
|
||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
# check-changelog:
|
||||
# runs-on: ubuntu-latest
|
||||
# if: |
|
||||
# contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||
# github.event_name == 'pull_request'
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@v3
|
||||
# with:
|
||||
# fetch-depth: 50
|
||||
# - name: Check that the CHANGELOG has been modified in the current branch
|
||||
# run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
|
||||
check-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||
github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
|
||||
|
||||
# lint-changelog:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@v2
|
||||
# - name: Check CHANGELOG max line length
|
||||
# run: |
|
||||
# max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
# if [ $max_line_length -ge 80 ]; then
|
||||
# echo "ERROR: CHANGELOG has lines longer than 80 characters."
|
||||
# exit 1
|
||||
# fi
|
||||
lint-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
if [ $max_line_length -ge 80 ]; then
|
||||
echo "ERROR: CHANGELOG has lines longer than 80 characters."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-mails:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -96,112 +96,121 @@ jobs:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
# lint-back:
|
||||
# runs-on: ubuntu-latest
|
||||
# defaults:
|
||||
# run:
|
||||
# working-directory: src/backend
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@v2
|
||||
# - name: Install Python
|
||||
# uses: actions/setup-python@v3
|
||||
# with:
|
||||
# python-version: "3.10"
|
||||
# - name: Install development dependencies
|
||||
# run: pip install --user .[dev]
|
||||
# - name: Check code formatting with ruff
|
||||
# run: ~/.local/bin/ruff format . --diff
|
||||
# - name: Lint code with ruff
|
||||
# run: ~/.local/bin/ruff check .
|
||||
# - name: Lint code with pylint
|
||||
# run: ~/.local/bin/pylint .
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: ~/.local/bin/pylint .
|
||||
|
||||
# test-back:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: build-mails
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
|
||||
# defaults:
|
||||
# run:
|
||||
# working-directory: src/backend
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
|
||||
# services:
|
||||
# postgres:
|
||||
# image: postgres:16
|
||||
# env:
|
||||
# POSTGRES_DB: impress
|
||||
# POSTGRES_USER: dinum
|
||||
# POSTGRES_PASSWORD: pass
|
||||
# ports:
|
||||
# - 5432:5432
|
||||
# # needed because the postgres container does not provide a healthcheck
|
||||
# options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: impress
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
ports:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
# env:
|
||||
# DJANGO_CONFIGURATION: Test
|
||||
# DJANGO_SETTINGS_MODULE: impress.settings
|
||||
# DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
|
||||
# OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
|
||||
# DB_HOST: localhost
|
||||
# DB_NAME: impress
|
||||
# DB_USER: dinum
|
||||
# DB_PASSWORD: pass
|
||||
# DB_PORT: 5432
|
||||
# STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
# AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
# AWS_S3_ACCESS_KEY_ID: impress
|
||||
# AWS_S3_SECRET_ACCESS_KEY: password
|
||||
env:
|
||||
DJANGO_CONFIGURATION: Test
|
||||
DJANGO_SETTINGS_MODULE: impress.settings
|
||||
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
|
||||
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
|
||||
DB_HOST: localhost
|
||||
DB_NAME: impress
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: impress
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@v4
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# - name: Create writable /data
|
||||
# run: |
|
||||
# sudo mkdir -p /data/media && \
|
||||
# sudo mkdir -p /data/static
|
||||
- name: Create writable /data
|
||||
run: |
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
|
||||
# - name: Restore the mail templates
|
||||
# uses: actions/cache@v4
|
||||
# id: mail-templates
|
||||
# with:
|
||||
# path: "src/backend/core/templates/mail"
|
||||
# key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
# - name: Start Minio
|
||||
# run: |
|
||||
# docker pull minio/minio
|
||||
# docker run -d --name minio \
|
||||
# -p 9000:9000 \
|
||||
# -e "MINIO_ACCESS_KEY=impress" \
|
||||
# -e "MINIO_SECRET_KEY=password" \
|
||||
# -v /data/media:/data \
|
||||
# minio/minio server --console-address :9001 /data
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker pull minio/minio
|
||||
docker run -d --name minio \
|
||||
-p 9000:9000 \
|
||||
-e "MINIO_ACCESS_KEY=impress" \
|
||||
-e "MINIO_SECRET_KEY=password" \
|
||||
-v /data/media:/data \
|
||||
minio/minio server --console-address :9001 /data
|
||||
|
||||
# - name: Configure MinIO
|
||||
# run: |
|
||||
# MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||
# docker exec ${MINIO} sh -c \
|
||||
# "mc alias set impress http://localhost:9000 impress password && \
|
||||
# mc alias ls && \
|
||||
# mc mb impress/impress-media-storage && \
|
||||
# mc version enable impress/impress-media-storage"
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
# - name: Install Python
|
||||
# uses: actions/setup-python@v3
|
||||
# with:
|
||||
# python-version: "3.10"
|
||||
- name: Wait for MinIO to be ready
|
||||
run: |
|
||||
dockerize -wait tcp://localhost:9000 -timeout 10s
|
||||
|
||||
# - name: Install development dependencies
|
||||
# run: pip install --user .[dev]
|
||||
- name: Configure MinIO
|
||||
run: |
|
||||
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||
docker exec ${MINIO} sh -c \
|
||||
"mc alias set impress http://localhost:9000 impress password && \
|
||||
mc alias ls && \
|
||||
mc mb impress/impress-media-storage && \
|
||||
mc version enable impress/impress-media-storage"
|
||||
|
||||
# - name: Install gettext (required to compile messages)
|
||||
# run: |
|
||||
# sudo apt-get update
|
||||
# sudo apt-get install -y gettext pandoc
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
# - name: Generate a MO file from strings extracted from the project
|
||||
# run: python manage.py compilemessages
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
# - name: Run tests
|
||||
# run: ~/.local/bin/pytest -n 2
|
||||
- name: Install gettext (required to compile messages)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -9,6 +9,54 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(frontend) More multi theme friendly #325
|
||||
- ♻️ Bootstrap frontend #257
|
||||
|
||||
## Fixed
|
||||
|
||||
🐛(frontend) invalidate queries after removing user #336
|
||||
|
||||
|
||||
## [1.5.1] - 2024-10-10
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(db) fix users duplicate #316
|
||||
|
||||
|
||||
## [1.5.0] - 2024-10-09
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(backend) add name fields to the user synchronized with OIDC #301
|
||||
- ✨(ci) add security scan #291
|
||||
- ♻️(frontend) Add versions #277
|
||||
- ✨(frontend) one-click document creation #275
|
||||
- ✨(frontend) edit title inline #275
|
||||
- 📱(frontend) mobile responsive #304
|
||||
- 🌐(frontend) Update translation #308
|
||||
|
||||
## Changed
|
||||
|
||||
- 💄(frontend) error alert closeable on editor #284
|
||||
- ♻️(backend) Change email content #283
|
||||
- 🛂(frontend) viewers and editors can access share modal #302
|
||||
- ♻️(frontend) remove footer on doc editor #313
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🛂(frontend) match email if no existing user matches the sub
|
||||
- 🐛(backend) gitlab oicd userinfo endpoint #232
|
||||
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
|
||||
- ♻️(backend) getting list of document versions available for a user #258
|
||||
- 🔧(backend) fix configuration to avoid different ssl warning #297
|
||||
- 🐛(frontend) fix editor break line not working #302
|
||||
|
||||
|
||||
## [1.4.0] - 2024-09-17
|
||||
|
||||
## Added
|
||||
|
||||
- ✨Add link public/authenticated/restricted access with read/editor roles #234
|
||||
@@ -17,13 +65,14 @@ and this project adheres to
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️ Allow null titles on documents for easier creation #234
|
||||
- ♻️(backend) Allow null titles on documents for easier creation #234
|
||||
- 🛂(backend) stop to list public doc to everyone #234
|
||||
- 🚚(frontend) change visibility in share modal #235
|
||||
- ⚡️(frontend) Improve summary #244
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛 Fix forcing ID when creating a document via API endpoint #234
|
||||
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
|
||||
- 🐛 Rebuild frontend dev container from makefile #248
|
||||
|
||||
|
||||
@@ -149,7 +198,10 @@ and this project adheres to
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.3.0...main
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.5.1...main
|
||||
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
||||
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
|
||||
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
|
||||
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
||||
|
||||
60
Dockerfile
60
Dockerfile
@@ -1,18 +1,20 @@
|
||||
# Django impress
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.10-slim-bullseye as base
|
||||
FROM python:3.12.6-alpine3.20 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apt-get update && \
|
||||
apt-get -y upgrade && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base as back-builder
|
||||
FROM base AS back-builder
|
||||
|
||||
RUN apk add \
|
||||
cargo
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -24,7 +26,7 @@ RUN mkdir /install && \
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 as mail-builder
|
||||
FROM node:20 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
@@ -35,15 +37,13 @@ RUN yarn install --frozen-lockfile && \
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
FROM base as link-collector
|
||||
FROM base AS link-collector
|
||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||
|
||||
# Install libpangocairo & rdfind
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
libpangocairo-1.0-0 \
|
||||
rdfind && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Install pango & rdfind
|
||||
RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
@@ -62,23 +62,21 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT}
|
||||
|
||||
# ---- Core application image ----
|
||||
FROM base as core
|
||||
FROM base AS core
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
gettext \
|
||||
libcairo2 \
|
||||
libffi-dev \
|
||||
libgdk-pixbuf2.0-0 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
pandoc \
|
||||
fonts-noto-color-emoji \
|
||||
shared-mime-info && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add \
|
||||
gettext \
|
||||
cairo \
|
||||
libffi-dev \
|
||||
gdk-pixbuf \
|
||||
pango \
|
||||
pandoc \
|
||||
font-noto-emoji \
|
||||
font-noto \
|
||||
shared-mime-info
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
@@ -102,15 +100,13 @@ WORKDIR /app
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
# ---- Development image ----
|
||||
FROM core as backend-development
|
||||
FROM core AS backend-development
|
||||
|
||||
# Switch back to the root user to install development dependencies
|
||||
USER root:root
|
||||
|
||||
# Install psql
|
||||
RUN apt-get update && \
|
||||
apt-get install -y postgresql-client && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall impress and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
@@ -130,7 +126,7 @@ ENV DB_HOST=postgresql \
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
# ---- Production image ----
|
||||
FROM core as backend-production
|
||||
FROM core AS backend-production
|
||||
|
||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||
|
||||
|
||||
45
Makefile
45
Makefile
@@ -81,7 +81,7 @@ bootstrap: \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
run-frontend-dev \
|
||||
run-with-frontend \
|
||||
migrate \
|
||||
demo \
|
||||
back-i18n-compile \
|
||||
@@ -90,11 +90,28 @@ bootstrap: \
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev --no-cache
|
||||
@$(COMPOSE) build frontend-dev --no-cache
|
||||
build: cache ?= --no-cache
|
||||
build: ## build the project containers
|
||||
@$(MAKE) build-backend cache=$(cache)
|
||||
@$(MAKE) build-yjs-provider cache=$(cache)
|
||||
@$(MAKE) build-frontend cache=$(cache)
|
||||
.PHONY: build
|
||||
|
||||
build-backend: cache ?=
|
||||
build-backend: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev $(cache)
|
||||
.PHONY: build-backend
|
||||
|
||||
build-yjs-provider: cache ?=
|
||||
build-yjs-provider: ## build the y-provider container
|
||||
@$(COMPOSE) build y-provider $(cache)
|
||||
.PHONY: build-yjs-provider
|
||||
|
||||
build-frontend: cache ?=
|
||||
build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend-dev $(cache)
|
||||
.PHONY: build-frontend
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
.PHONY: down
|
||||
@@ -110,6 +127,11 @@ run: ## start the wsgi (production) and development server
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run
|
||||
|
||||
run-with-frontend: ## Start all the containers needed (backend to frontend)
|
||||
@$(MAKE) run
|
||||
@$(COMPOSE) up --force-recreate -d frontend-dev
|
||||
.PHONY: run-with-frontend
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@$(COMPOSE) ps
|
||||
.PHONY: status
|
||||
@@ -286,10 +308,15 @@ help:
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
|
||||
.PHONY: help
|
||||
|
||||
# Front
|
||||
run-frontend-dev: ## Install and run the frontend dev
|
||||
@$(COMPOSE) up --force-recreate -d frontend-dev
|
||||
.PHONY: run-frontend-dev
|
||||
# Front
|
||||
frontend-install: ## install the frontend locally
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn
|
||||
.PHONY: frontend-install
|
||||
|
||||
run-frontend-development: ## Run the frontend in development mode
|
||||
@$(COMPOSE) stop frontend-dev
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||
.PHONY: run-frontend-development
|
||||
|
||||
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||
cd $(PATH_FRONT) && yarn i18n:extract
|
||||
@@ -314,7 +341,7 @@ start-tilt: ## start the kubernetes cluster using kind
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
VERSION_TYPE ?= minor
|
||||
bump-packages-version: VERSION_TYPE ?= minor
|
||||
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
|
||||
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
|
||||
53
README.md
53
README.md
@@ -1,9 +1,15 @@
|
||||
# Impress
|
||||
|
||||
Impress prints your markdown to pdf from predefined templates with user and role based access rights.
|
||||
Impress is a web application for real-time collaborative text editing with user and role based access rights.
|
||||
Features include :
|
||||
- User authentication through OIDC
|
||||
- BlocNote.js text editing experience (markdown support, dynamic conversion, block structure, slash commands for block creation)
|
||||
- Document export to pdf and docx from predefined templates
|
||||
- Granular document permissions
|
||||
- Public link sharing
|
||||
- Offline mode
|
||||
|
||||
Impress is built on top of [Django Rest
|
||||
Framework](https://www.django-rest-framework.org/) and [Next.js](https://nextjs.org/).
|
||||
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [BlocNote.js](https://www.blocknotejs.org/)
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -31,14 +37,6 @@ The easiest way to start working on the project is to use GNU Make:
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
Then you can access to the project in development mode by going to http://localhost:3000.
|
||||
You will be prompted to log in, the default credentials are:
|
||||
```bash
|
||||
username: impress
|
||||
password: impress
|
||||
```
|
||||
---
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs
|
||||
database migrations and compile translations. It's a good idea to use this
|
||||
command each time you are pulling code from the project repository to avoid
|
||||
@@ -46,12 +44,41 @@ dependency-releated or migration-releated issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
|
||||
Note that if you need to run them afterwards, you can use the eponym Make rule:
|
||||
You can access to the project by going to http://localhost:3000.
|
||||
You will be prompted to log in, the default credentials are:
|
||||
```bash
|
||||
username: impress
|
||||
password: impress
|
||||
```
|
||||
|
||||
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
|
||||
|
||||
```bash
|
||||
$ make run-frontend-dev
|
||||
$ make run-with-frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
|
||||
To do so, install the frontend dependencies with the following command:
|
||||
|
||||
```bash
|
||||
$ make frontend-install
|
||||
```
|
||||
And run the frontend locally in development mode with the following command:
|
||||
|
||||
```bash
|
||||
$ make run-frontend-development
|
||||
```
|
||||
|
||||
To start all the services, except the frontend container, you can use the following command:
|
||||
|
||||
```bash
|
||||
$ make run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Adding content
|
||||
|
||||
You can create a basic demo site by running:
|
||||
|
||||
@@ -119,13 +119,20 @@ services:
|
||||
depends_on:
|
||||
- keycloak
|
||||
|
||||
nginx-front:
|
||||
image: nginx:1.25
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
args:
|
||||
API_ORIGIN: "http://localhost:8071"
|
||||
Y_PROVIDER_URL: "ws://localhost:4444"
|
||||
MEDIA_URL: "http://localhost:8083"
|
||||
SW_DEACTIVATED: "true"
|
||||
image: impress:frontend-development
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./src/frontend/apps/impress/conf/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./src/frontend/apps/impress/out:/usr/share/nginx/html
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
@@ -161,21 +168,6 @@ services:
|
||||
- /home/frontend/servers/y-provider/node_modules/
|
||||
- /home/frontend/servers/y-provider/dist/
|
||||
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: impress-dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./src/frontend/apps/impress:/home/frontend/apps/impress
|
||||
- /home/frontend/node_modules/
|
||||
depends_on:
|
||||
- y-provider
|
||||
- celery-dev
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
|
||||
2
secrets
2
secrets
Submodule secrets updated: 2643697e5f...38594182e8
@@ -447,10 +447,10 @@ max-bool-expr=5
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of locals for function / method body
|
||||
max-locals=15
|
||||
max-locals=20
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=7
|
||||
max-parents=10
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
@@ -29,7 +29,19 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
|
||||
(
|
||||
_("Personal info"),
|
||||
{
|
||||
"fields": (
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"language",
|
||||
"timezone",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
@@ -58,6 +70,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"sub",
|
||||
"full_name",
|
||||
"admin_email",
|
||||
"email",
|
||||
"is_active",
|
||||
@@ -68,9 +81,24 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
|
||||
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
|
||||
search_fields = ("id", "sub", "admin_email", "email")
|
||||
ordering = (
|
||||
"is_active",
|
||||
"-is_superuser",
|
||||
"-is_staff",
|
||||
"-is_device",
|
||||
"-updated_at",
|
||||
"full_name",
|
||||
)
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
|
||||
|
||||
@admin.register(models.Template)
|
||||
|
||||
@@ -16,8 +16,8 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "sub", "email"]
|
||||
read_only_fields = ["id", "sub", "email"]
|
||||
fields = ["id", "email", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
class BaseAccessSerializer(serializers.ModelSerializer):
|
||||
@@ -343,10 +343,10 @@ class InvitationSerializer(serializers.ModelSerializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class DocumentVersionSerializer(serializers.Serializer):
|
||||
"""Serialize Versions."""
|
||||
class VersionFilterSerializer(serializers.Serializer):
|
||||
"""Validate version filters applied to the list endpoint."""
|
||||
|
||||
etag = serializers.CharField()
|
||||
is_latest = serializers.BooleanField()
|
||||
last_modified = serializers.DateTimeField()
|
||||
version_id = serializers.CharField()
|
||||
version_id = serializers.CharField(required=False, allow_blank=True)
|
||||
page_size = serializers.IntegerField(
|
||||
required=False, min_value=1, max_value=50, default=20
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.models import (
|
||||
Min,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
@@ -31,7 +32,6 @@ from rest_framework import (
|
||||
)
|
||||
|
||||
from core import models
|
||||
from core.utils import email_invitation
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
|
||||
@@ -374,28 +374,36 @@ class DocumentViewSet(
|
||||
Return the document's versions but only those created after the user got access
|
||||
to the document
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
user = request.user
|
||||
if not user.is_authenticated:
|
||||
raise exceptions.PermissionDenied("Authentication required.")
|
||||
|
||||
# Validate query parameters using dedicated serializer
|
||||
serializer = serializers.VersionFilterSerializer(data=request.query_params)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
document = self.get_object()
|
||||
user = request.user
|
||||
from_datetime = min(
|
||||
access.created_at
|
||||
for access in document.accesses.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
|
||||
# Users should not see version history dating from before they gained access to the
|
||||
# document. Filter to get the minimum access date for the logged-in user
|
||||
access_queryset = document.accesses.filter(
|
||||
Q(user=user) | Q(team__in=user.teams)
|
||||
).aggregate(min_date=Min("created_at"))
|
||||
|
||||
# Handle the case where the user has no accesses
|
||||
min_datetime = access_queryset["min_date"]
|
||||
if not min_datetime:
|
||||
return exceptions.PermissionDenied(
|
||||
"Only users with specific access can see version history"
|
||||
)
|
||||
|
||||
versions_data = document.get_versions_slice(
|
||||
from_version_id=serializer.validated_data.get("version_id"),
|
||||
min_datetime=min_datetime,
|
||||
page_size=serializer.validated_data.get("page_size"),
|
||||
)
|
||||
|
||||
versions_data = document.get_versions_slice(from_datetime=from_datetime)[
|
||||
"versions"
|
||||
]
|
||||
paginator = pagination.PageNumberPagination()
|
||||
paginated_versions = paginator.paginate_queryset(versions_data, request)
|
||||
serialized_versions = serializers.DocumentVersionSerializer(
|
||||
paginated_versions, many=True
|
||||
)
|
||||
|
||||
return paginator.get_paginated_response(serialized_versions.data)
|
||||
return drf_response.Response(versions_data)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
@@ -415,13 +423,13 @@ class DocumentViewSet(
|
||||
# Don't let users access versions that were created before they were given access
|
||||
# to the document
|
||||
user = request.user
|
||||
from_datetime = min(
|
||||
min_datetime = min(
|
||||
access.created_at
|
||||
for access in document.accesses.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
)
|
||||
)
|
||||
if response["LastModified"] < from_datetime:
|
||||
if response["LastModified"] < min_datetime:
|
||||
raise Http404
|
||||
|
||||
if request.method == "DELETE":
|
||||
@@ -567,9 +575,10 @@ class DocumentAccessViewSet(
|
||||
def perform_create(self, serializer):
|
||||
"""Add a new access to the document and send an email to the new added user."""
|
||||
access = serializer.save()
|
||||
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
email_invitation(language, access.user.email, access.document.id)
|
||||
access.document.email_invitation(
|
||||
language, access.user.email, access.role, self.request.user.email
|
||||
)
|
||||
|
||||
|
||||
class TemplateViewSet(
|
||||
@@ -769,4 +778,6 @@ class InvitationViewset(
|
||||
invitation = serializer.save()
|
||||
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
email_invitation(language, invitation.email, invitation.document.id)
|
||||
invitation.document.email_invitation(
|
||||
language, invitation.email, invitation.role, self.request.user.email
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Authentication Backends for the Impress core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -45,56 +46,75 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
|
||||
try:
|
||||
userinfo = user_response.json()
|
||||
except ValueError:
|
||||
try:
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
except Exception as e:
|
||||
raise SuspiciousOperation(
|
||||
_("Invalid response format or token verification failed")
|
||||
) from e
|
||||
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The ID token.
|
||||
- payload (dict): The user payload.
|
||||
|
||||
Returns:
|
||||
- User: An existing or newly created User instance.
|
||||
|
||||
Raises:
|
||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
||||
"""
|
||||
"""Return a User based on userinfo. Create a new user if no match is found."""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
sub = user_info.get("sub")
|
||||
email = user_info.get("email")
|
||||
|
||||
if sub is None:
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
full_name = self.compute_full_name(user_info)
|
||||
short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME)
|
||||
|
||||
claims = {
|
||||
"email": email,
|
||||
"full_name": full_name,
|
||||
"short_name": short_name,
|
||||
}
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
try:
|
||||
user = User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(user_info)
|
||||
else:
|
||||
user = None
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
if user:
|
||||
self.update_user_if_needed(user, claims)
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, claims):
|
||||
"""Return a newly created User instance."""
|
||||
|
||||
sub = claims.get("sub")
|
||||
|
||||
if sub is None:
|
||||
raise SuspiciousOperation(
|
||||
_("Claims contained no recognizable user identification")
|
||||
)
|
||||
|
||||
user = User.objects.create(
|
||||
sub=sub,
|
||||
email=claims.get("email"),
|
||||
password="!", # noqa: S106
|
||||
def compute_full_name(self, user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
|
||||
full_name = " ".join(
|
||||
user_info[field] for field in name_fields if user_info.get(field)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
return user
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return User.objects.get(sub=sub, is_active=True)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email=email, is_active=True)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
has_changed = any(
|
||||
value and value != getattr(user, key) for key, value in claims.items()
|
||||
)
|
||||
if has_changed:
|
||||
updated_claims = {key: value for key, value in claims.items() if value}
|
||||
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -22,6 +22,8 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
full_name = factory.Faker("name")
|
||||
short_name = factory.Faker("first_name")
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
password = make_password("password")
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-29 03:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='full_name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='short_name',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
]
|
||||
128
src/backend/core/migrations/0007_fix_users_duplicate.py
Normal file
128
src/backend/core/migrations/0007_fix_users_duplicate.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-10 11:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
procedure = """
|
||||
DO $$
|
||||
DECLARE
|
||||
user_email TEXT;
|
||||
BEGIN
|
||||
-- Step 1: Create a temporary table (without the unique constraint)
|
||||
-- impress_document_access
|
||||
DROP TABLE IF EXISTS impress_document_access_tmp;
|
||||
CREATE TEMP TABLE impress_document_access_tmp AS
|
||||
SELECT * FROM impress_document_access;
|
||||
|
||||
-- impress_link_trace
|
||||
DROP TABLE IF EXISTS impress_link_trace_tmp;
|
||||
CREATE TEMP TABLE impress_link_trace_tmp AS
|
||||
SELECT * FROM impress_link_trace;
|
||||
|
||||
-- Step 2: Loop through each email that appears more than once
|
||||
FOR user_email IN
|
||||
SELECT email
|
||||
FROM impress_user
|
||||
GROUP BY email
|
||||
HAVING COUNT(email) > 1
|
||||
LOOP
|
||||
-- Step 3: Update user_id in the temporary table based on email
|
||||
-- For impress_document_access
|
||||
UPDATE impress_document_access_tmp
|
||||
SET user_id = (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE user_id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
);
|
||||
|
||||
-- For impress_link_trace
|
||||
UPDATE impress_link_trace_tmp
|
||||
SET user_id = (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE user_id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
);
|
||||
|
||||
-- update impress_invitation
|
||||
UPDATE impress_invitation
|
||||
SET issuer_id = (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE issuer_id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
);
|
||||
|
||||
DELETE FROM impress_user
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
)
|
||||
AND id != (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
RAISE NOTICE 'Processed updates for email: %', user_email;
|
||||
END LOOP;
|
||||
|
||||
-- Step 4: Remove duplicate rows from the temporary table, keeping only one row per (document_id, user_id)
|
||||
-- For impress_document_access
|
||||
DELETE FROM impress_document_access_tmp a
|
||||
USING impress_document_access_tmp b
|
||||
WHERE a.ctid < b.ctid -- Keep one row
|
||||
AND a.document_id = b.document_id
|
||||
AND a.user_id = b.user_id;
|
||||
|
||||
-- Step 5: Replace the original table with the cleaned-up temporary table
|
||||
TRUNCATE TABLE impress_document_access;
|
||||
|
||||
-- Insert cleaned-up data back into the original table
|
||||
INSERT INTO impress_document_access
|
||||
SELECT * FROM impress_document_access_tmp;
|
||||
|
||||
-- For impress_link_trace
|
||||
DELETE FROM impress_link_trace_tmp a
|
||||
USING impress_link_trace_tmp b
|
||||
WHERE a.ctid < b.ctid -- Keep one row
|
||||
AND a.document_id = b.document_id
|
||||
AND a.user_id = b.user_id;
|
||||
|
||||
-- Step 5: Replace the original table with the cleaned-up temporary table
|
||||
TRUNCATE TABLE impress_link_trace;
|
||||
|
||||
-- Insert cleaned-up data back into the original table
|
||||
INSERT INTO impress_link_trace
|
||||
SELECT * FROM impress_link_trace_tmp;
|
||||
|
||||
RAISE NOTICE 'Update and deduplication process completed.';
|
||||
END $$;
|
||||
"""
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_add_user_full_name_and_short_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(procedure),
|
||||
]
|
||||
@@ -3,6 +3,7 @@ Declare and configure the models for the impress core application
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import smtplib
|
||||
import tempfile
|
||||
import textwrap
|
||||
import uuid
|
||||
@@ -13,16 +14,20 @@ from logging import getLogger
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import exceptions, mail, validators
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.mail import send_mail
|
||||
from django.db import models
|
||||
from django.http import FileResponse
|
||||
from django.template.base import Template as DjangoTemplate
|
||||
from django.template.context import Context
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import html, timezone
|
||||
from django.utils.functional import cached_property, lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import override
|
||||
|
||||
import frontmatter
|
||||
import markdown
|
||||
@@ -140,6 +145,10 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
|
||||
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
|
||||
|
||||
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
||||
|
||||
# Unlike the "email" field which stores the email coming from the OIDC token, this field
|
||||
@@ -411,73 +420,62 @@ class Document(BaseModel):
|
||||
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
|
||||
)
|
||||
|
||||
def get_versions_slice(
|
||||
self, from_version_id="", from_datetime=None, page_size=None
|
||||
):
|
||||
def get_versions_slice(self, from_version_id="", min_datetime=None, page_size=None):
|
||||
"""Get document versions from object storage with pagination and starting conditions"""
|
||||
# /!\ Trick here /!\
|
||||
# The "KeyMarker" and "VersionIdMarker" fields must either be both set or both not set.
|
||||
# The error we get otherwise is not helpful at all.
|
||||
token = {}
|
||||
markers = {}
|
||||
if from_version_id:
|
||||
token.update(
|
||||
markers.update(
|
||||
{"KeyMarker": self.file_key, "VersionIdMarker": from_version_id}
|
||||
)
|
||||
|
||||
if from_datetime:
|
||||
response = default_storage.connection.meta.client.list_object_versions(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Prefix=self.file_key,
|
||||
MaxKeys=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
|
||||
**token,
|
||||
)
|
||||
|
||||
# Find the first version after the given datetime
|
||||
version = None
|
||||
for version in response.get("Versions", []):
|
||||
if version["LastModified"] >= from_datetime:
|
||||
token = {
|
||||
"KeyMarker": self.file_key,
|
||||
"VersionIdMarker": version["VersionId"],
|
||||
}
|
||||
break
|
||||
else:
|
||||
if version is None or version["LastModified"] < from_datetime:
|
||||
if response["NextVersionIdMarker"]:
|
||||
return self.get_versions_slice(
|
||||
from_version_id=response["NextVersionIdMarker"],
|
||||
page_size=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
|
||||
from_datetime=from_datetime,
|
||||
)
|
||||
return {
|
||||
"next_version_id_marker": "",
|
||||
"is_truncated": False,
|
||||
"versions": [],
|
||||
}
|
||||
real_page_size = (
|
||||
min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
|
||||
if page_size
|
||||
else settings.DOCUMENT_VERSIONS_PAGE_SIZE
|
||||
)
|
||||
|
||||
response = default_storage.connection.meta.client.list_object_versions(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Prefix=self.file_key,
|
||||
MaxKeys=min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
|
||||
if page_size
|
||||
else settings.DOCUMENT_VERSIONS_PAGE_SIZE,
|
||||
**token,
|
||||
# compensate the latest version that we exclude below and get one more to
|
||||
# know if there are more pages
|
||||
MaxKeys=real_page_size + 2,
|
||||
**markers,
|
||||
)
|
||||
|
||||
min_last_modified = min_datetime or self.created_at
|
||||
versions = [
|
||||
{
|
||||
key_snake: version[key_camel]
|
||||
for key_snake, key_camel in [
|
||||
("etag", "ETag"),
|
||||
("is_latest", "IsLatest"),
|
||||
("last_modified", "LastModified"),
|
||||
("version_id", "VersionId"),
|
||||
]
|
||||
}
|
||||
for version in response.get("Versions", [])
|
||||
if version["LastModified"] >= min_last_modified
|
||||
and version["IsLatest"] is False
|
||||
]
|
||||
results = versions[:real_page_size]
|
||||
|
||||
count = len(results)
|
||||
if count == len(versions):
|
||||
is_truncated = False
|
||||
next_version_id_marker = ""
|
||||
else:
|
||||
is_truncated = True
|
||||
next_version_id_marker = versions[count - 1]["version_id"]
|
||||
|
||||
return {
|
||||
"next_version_id_marker": response["NextVersionIdMarker"],
|
||||
"is_truncated": response["IsTruncated"],
|
||||
"versions": [
|
||||
{
|
||||
key_snake: version[key_camel]
|
||||
for key_camel, key_snake in [
|
||||
("ETag", "etag"),
|
||||
("IsLatest", "is_latest"),
|
||||
("LastModified", "last_modified"),
|
||||
("VersionId", "version_id"),
|
||||
]
|
||||
}
|
||||
for version in response.get("Versions", [])
|
||||
],
|
||||
"next_version_id_marker": next_version_id_marker,
|
||||
"is_truncated": is_truncated,
|
||||
"versions": results,
|
||||
"count": count,
|
||||
}
|
||||
|
||||
def delete_version(self, version_id):
|
||||
@@ -522,6 +520,39 @@ class Document(BaseModel):
|
||||
"versions_retrieve": can_get_versions,
|
||||
}
|
||||
|
||||
def email_invitation(self, language, email, role, username_sender):
|
||||
"""Send email invitation."""
|
||||
|
||||
domain = Site.objects.get_current().domain
|
||||
|
||||
try:
|
||||
with override(language):
|
||||
title = _("%(username)s shared a document with you: %(document)s") % {
|
||||
"username": username_sender,
|
||||
"document": self.title,
|
||||
}
|
||||
template_vars = {
|
||||
"title": title,
|
||||
"domain": domain,
|
||||
"document": self,
|
||||
"link": f"{domain}/docs/{self.id}/",
|
||||
"username": username_sender,
|
||||
"role": RoleChoices(role).label.lower(),
|
||||
}
|
||||
msg_html = render_to_string("mail/html/invitation.html", template_vars)
|
||||
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
|
||||
send_mail(
|
||||
title,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", email, exception)
|
||||
|
||||
|
||||
class LinkTrace(BaseModel):
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
import re
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
@@ -34,6 +38,130 @@ def test_authentication_getter_existing_user_no_email(
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub but matches the email,
|
||||
the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should not match users by email, even if the email matches.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
# Since the sub doesn't match, it should create a new user
|
||||
assert models.User.objects.count() == 2
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the user's info contains an email and targets an existing user,
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(full_name="John Doe", short_name="John")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"email": user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# Only 1 query because email and names have not changed
|
||||
with django_assert_num_queries(1):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"first_name, last_name, email",
|
||||
[
|
||||
("Jack", "Doe", "john.doe@example.com"),
|
||||
("John", "Duy", "john.doe@example.com"),
|
||||
("John", "Doe", "jack.duy@example.com"),
|
||||
("Jack", "Duy", "jack.duy@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the email or name fields on the user when they change.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == email
|
||||
assert user.full_name == f"{first_name:s} {last_name:s}"
|
||||
assert user.short_name == first_name
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
@@ -52,6 +180,8 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
@@ -77,11 +207,13 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch):
|
||||
def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypatch):
|
||||
"""The user's info doesn't contain a sub."""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
@@ -102,3 +234,74 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_json_response():
|
||||
"""Test get_userinfo method with a JSON response."""
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
json={
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "john.doe@example.com",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "John"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "john.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_token_response(monkeypatch):
|
||||
"""Test get_userinfo method with a token response."""
|
||||
|
||||
responses.add(
|
||||
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
|
||||
)
|
||||
|
||||
def mock_verify_token(self, token): # pylint: disable=unused-argument
|
||||
return {
|
||||
"first_name": "Jane",
|
||||
"last_name": "Doe",
|
||||
"email": "jane.doe@example.com",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "Jane"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "jane.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_invalid_response():
|
||||
"""
|
||||
Test get_userinfo method with an invalid JWT response that
|
||||
causes verify_token to raise an error.
|
||||
"""
|
||||
|
||||
responses.add(
|
||||
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Invalid response format or token verification failed",
|
||||
):
|
||||
oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
@@ -171,7 +171,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [other_user["email"]]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Invitation to join Docs!" in email_content
|
||||
assert f"{user.email} shared a document with you: {document.title}" in email_content
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
|
||||
|
||||
@@ -225,5 +225,5 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [other_user["email"]]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Invitation to join Docs!" in email_content
|
||||
assert f"{user.email} shared a document with you: {document.title}" in email_content
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
|
||||
@@ -118,7 +118,10 @@ def test_api_document_invitations__create__privileged_members(
|
||||
email = mail.outbox[0]
|
||||
assert email.to == ["guest@example.com"]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Invitation to join Docs!" in email_content
|
||||
assert (
|
||||
f"{user.email} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
else:
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert models.Invitation.objects.exists() is False
|
||||
@@ -158,7 +161,10 @@ def test_api_document_invitations__create__email_from_content_language():
|
||||
assert email.to == ["guest@example.com"]
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Invitation à rejoindre Docs !" in email_content
|
||||
assert (
|
||||
f"{user.email} a partagé un document avec vous: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations__create__email_from_content_language_not_supported():
|
||||
@@ -196,7 +202,7 @@ def test_api_document_invitations__create__email_from_content_language_not_suppo
|
||||
assert email.to == ["guest@example.com"]
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Invitation to join Docs!" in email_content
|
||||
assert f"{user.email} shared a document with you: {document.title}" in email_content
|
||||
|
||||
|
||||
def test_api_document_invitations__create__issuer_and_document_override():
|
||||
|
||||
@@ -60,7 +60,7 @@ def test_api_document_versions_list_authenticated_unrelated(reach):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_versions_list_authenticated_related(via, mock_user_teams):
|
||||
def test_api_document_versions_list_authenticated_related_success(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to list document versions for a document
|
||||
to which they are directly related, whatever their role in the document.
|
||||
@@ -95,12 +95,12 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_teams):
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 0
|
||||
assert content["count"] == 0
|
||||
|
||||
# Add a new version to the document
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
for i in range(3):
|
||||
document.content = f"new content {i:d}"
|
||||
document.save()
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/",
|
||||
@@ -108,8 +108,100 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_teams):
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
# The current version is not listed
|
||||
assert content["count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_versions_list_authenticated_related_pagination(
|
||||
via, mock_user_teams
|
||||
):
|
||||
"""
|
||||
The list of versions should be paginated and exclude versions that were created prior to the
|
||||
user gaining access to the document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
for i in range(3):
|
||||
document.content = f"before {i:d}"
|
||||
document.save()
|
||||
|
||||
if via == USER:
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document,
|
||||
user=user,
|
||||
role=random.choice(models.RoleChoices.choices)[0],
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document,
|
||||
team="lasuite",
|
||||
role=random.choice(models.RoleChoices.choices)[0],
|
||||
)
|
||||
|
||||
for i in range(4):
|
||||
document.content = f"after {i:d}"
|
||||
document.save()
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/",
|
||||
)
|
||||
|
||||
content = response.json()
|
||||
assert content["is_truncated"] is False
|
||||
# The current version is not listed
|
||||
assert content["count"] == 3
|
||||
assert content["next_version_id_marker"] == ""
|
||||
all_version_ids = [version["version_id"] for version in content["versions"]]
|
||||
|
||||
# - set page size
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2",
|
||||
)
|
||||
|
||||
content = response.json()
|
||||
assert content["count"] == 2
|
||||
assert content["is_truncated"] is True
|
||||
marker = content["next_version_id_marker"]
|
||||
assert marker == all_version_ids[1]
|
||||
assert [
|
||||
version["version_id"] for version in content["versions"]
|
||||
] == all_version_ids[:2]
|
||||
|
||||
# - get page 2
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2&version_id={marker:s}",
|
||||
)
|
||||
|
||||
content = response.json()
|
||||
assert content["count"] == 1
|
||||
assert content["is_truncated"] is False
|
||||
assert content["next_version_id_marker"] == ""
|
||||
assert content["versions"][0]["version_id"] == all_version_ids[2]
|
||||
|
||||
|
||||
def test_api_document_versions_list_exceeds_max_page_size():
|
||||
"""Page size should not exceed the limit set on the serializer"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
document.content = "version 2"
|
||||
document.save()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/versions/?page_size=51")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"page_size": ["Ensure this value is less than or equal to 50."]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@@ -119,6 +211,9 @@ def test_api_document_versions_retrieve_anonymous(reach):
|
||||
restricted or authenticated link reach.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/"
|
||||
@@ -142,6 +237,9 @@ def test_api_document_versions_retrieve_authenticated_unrelated(reach):
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.get(
|
||||
@@ -157,7 +255,7 @@ def test_api_document_versions_retrieve_authenticated_unrelated(reach):
|
||||
def test_api_document_versions_retrieve_authenticated_related(via, mock_user_teams):
|
||||
"""
|
||||
A user who is related to a document should be allowed to retrieve the
|
||||
associated document user accesses.
|
||||
associated document versions.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -165,6 +263,10 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 1
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
if via == USER:
|
||||
@@ -173,6 +275,8 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
||||
|
||||
time.sleep(1) # minio stores datetimes with the precision of a second
|
||||
|
||||
# Versions created before the document was shared should not be seen by the user
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
@@ -180,11 +284,26 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
# Create a new version should make it available to the user
|
||||
time.sleep(1) # minio stores datetimes with the precision of a second
|
||||
document.content = "new content"
|
||||
# Create a new version should not make it available to the user because
|
||||
# only the current version is available to the user but it is excluded
|
||||
# from the list
|
||||
document.content = "new content 1"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 2
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
# Adding one more version should make the previous version available to the user
|
||||
document.content = "new content 2"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 3
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.get(
|
||||
@@ -192,7 +311,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["content"] == "new content"
|
||||
assert response.json()["content"] == "new content 1"
|
||||
|
||||
|
||||
def test_api_document_versions_create_anonymous():
|
||||
@@ -260,10 +379,15 @@ def test_api_document_versions_create_authenticated_related(via, mock_user_teams
|
||||
def test_api_document_versions_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a document version."""
|
||||
access = factories.UserDocumentAccessFactory()
|
||||
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
|
||||
document = access.document
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 1
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
{"foo": "bar"},
|
||||
format="json",
|
||||
)
|
||||
@@ -281,7 +405,12 @@ def test_api_document_versions_update_authenticated_unrelated():
|
||||
client.force_login(user)
|
||||
|
||||
access = factories.UserDocumentAccessFactory()
|
||||
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
|
||||
document = access.document
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 1
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
|
||||
@@ -303,7 +432,6 @@ def test_api_document_versions_update_authenticated_related(via, mock_user_teams
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
@@ -311,6 +439,14 @@ def test_api_document_versions_update_authenticated_related(via, mock_user_teams
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
||||
|
||||
time.sleep(1) # minio stores datetimes with the precision of a second
|
||||
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 1
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id!s}/",
|
||||
{"foo": "bar"},
|
||||
@@ -345,6 +481,9 @@ def test_api_document_versions_delete_authenticated(reach):
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.delete(
|
||||
@@ -381,13 +520,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
|
||||
document.save()
|
||||
|
||||
versions = document.get_versions_slice()["versions"]
|
||||
assert len(versions) == 2
|
||||
|
||||
version_id = versions[1]["version_id"]
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert len(versions) == 1
|
||||
|
||||
version_id = versions[0]["version_id"]
|
||||
response = client.delete(
|
||||
@@ -396,7 +529,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
|
||||
assert response.status_code == 403
|
||||
|
||||
versions = document.get_versions_slice()["versions"]
|
||||
assert len(versions) == 2
|
||||
assert len(versions) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@@ -421,19 +554,25 @@ def test_api_document_versions_delete_administrator_or_owner(via, mock_user_team
|
||||
|
||||
# Create a new version should make it available to the user
|
||||
time.sleep(1) # minio stores datetimes with the precision of a second
|
||||
document.content = "new content"
|
||||
document.content = "new content 1"
|
||||
document.save()
|
||||
|
||||
versions = document.get_versions_slice()["versions"]
|
||||
assert len(versions) == 2
|
||||
assert len(versions) == 1
|
||||
|
||||
version_id = versions[1]["version_id"]
|
||||
version_id = versions[0]["version_id"]
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
# 404 because the version was created before the user was given access to the document
|
||||
assert response.status_code == 404
|
||||
|
||||
document.content = "new content 2"
|
||||
document.save()
|
||||
|
||||
versions = document.get_versions_slice()["versions"]
|
||||
assert len(versions) == 2
|
||||
|
||||
version_id = versions[0]["version_id"]
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
|
||||
@@ -120,6 +120,8 @@ def test_api_users_retrieve_me_authenticated():
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"short_name": user.short_name,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
Unit tests for the Document model
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core import mail
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -255,7 +261,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
}
|
||||
|
||||
|
||||
def test_models_documents_get_versions_slice(settings):
|
||||
def test_models_documents_get_versions_slice_pagination(settings):
|
||||
"""
|
||||
The "get_versions_slice" method should allow navigating all versions of
|
||||
the document with pagination.
|
||||
@@ -268,7 +274,7 @@ def test_models_documents_get_versions_slice(settings):
|
||||
document.content = f"bar{i:d}"
|
||||
document.save()
|
||||
|
||||
# Add a version not related to the first document
|
||||
# Add a document version not related to the first document
|
||||
factories.DocumentFactory()
|
||||
|
||||
# - Get default max versions
|
||||
@@ -286,7 +292,7 @@ def test_models_documents_get_versions_slice(settings):
|
||||
from_version_id=response["next_version_id_marker"]
|
||||
)
|
||||
assert response["is_truncated"] is False
|
||||
assert len(response["versions"]) == 3
|
||||
assert len(response["versions"]) == 2
|
||||
assert response["next_version_id_marker"] == ""
|
||||
|
||||
# - Get custom max versions
|
||||
@@ -296,6 +302,30 @@ def test_models_documents_get_versions_slice(settings):
|
||||
assert response["next_version_id_marker"] != ""
|
||||
|
||||
|
||||
def test_models_documents_get_versions_slice_min_datetime():
|
||||
"""
|
||||
The "get_versions_slice" method should filter out versions anterior to
|
||||
the from_datetime passed in argument and the current version.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
from_dt = []
|
||||
for i in range(6):
|
||||
from_dt.append(timezone.now())
|
||||
document.content = f"bar{i:d}"
|
||||
document.save()
|
||||
|
||||
response = document.get_versions_slice(min_datetime=from_dt[2])
|
||||
|
||||
assert len(response["versions"]) == 3
|
||||
for version in response["versions"]:
|
||||
assert version["last_modified"] > from_dt[2]
|
||||
|
||||
response = document.get_versions_slice(min_datetime=from_dt[4])
|
||||
|
||||
assert len(response["versions"]) == 1
|
||||
assert response["versions"][0]["last_modified"] > from_dt[4]
|
||||
|
||||
|
||||
def test_models_documents_version_duplicate():
|
||||
"""A new version should be created in object storage only if the content has changed."""
|
||||
document = factories.DocumentFactory()
|
||||
@@ -322,3 +352,94 @@ def test_models_documents_version_duplicate():
|
||||
Bucket=default_storage.bucket_name, Prefix=file_key
|
||||
)
|
||||
assert len(response["Versions"]) == 2
|
||||
|
||||
|
||||
def test_models_documents__email_invitation__success():
|
||||
"""
|
||||
The email invitation is sent successfully.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
document.email_invitation(
|
||||
"en", "guest@example.com", models.RoleChoices.EDITOR, "sender@example.com"
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == ["guest@example.com"]
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
assert (
|
||||
f"sender@example.com invited you as an editor on the following document : {document.title}"
|
||||
in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
|
||||
def test_models_documents__email_invitation__success_fr():
|
||||
"""
|
||||
The email invitation is sent successfully in french.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
document.email_invitation(
|
||||
"fr-fr", "guest2@example.com", models.RoleChoices.OWNER, "sender2@example.com"
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == ["guest2@example.com"]
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
assert (
|
||||
f"sender2@example.com vous a invité en tant que propriétaire "
|
||||
f"sur le document suivant : {document.title}" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.models.send_mail",
|
||||
side_effect=smtplib.SMTPException("Error SMTPException"),
|
||||
)
|
||||
@mock.patch.object(Logger, "error")
|
||||
def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail):
|
||||
"""Check mail behavior when an SMTP error occurs when sent an email invitation."""
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
document.email_invitation(
|
||||
"en", "guest3@example.com", models.RoleChoices.ADMIN, "sender3@example.com"
|
||||
)
|
||||
|
||||
# No email has been sent
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
# Logger should be called
|
||||
mock_logger.assert_called_once()
|
||||
|
||||
(
|
||||
_,
|
||||
email,
|
||||
exception,
|
||||
) = mock_logger.call_args.args
|
||||
|
||||
assert email == "guest3@example.com"
|
||||
assert isinstance(exception, smtplib.SMTPException)
|
||||
|
||||
@@ -3,6 +3,7 @@ Unit tests for the Template model
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
@@ -203,7 +204,7 @@ def test_models_templates__generate_word():
|
||||
"pypandoc.convert_text",
|
||||
side_effect=RuntimeError("Conversion failed"),
|
||||
)
|
||||
def test_models_templates__generate_word__raise_error(_mock_send_mail):
|
||||
def test_models_templates__generate_word__raise_error(_mock_pypandoc):
|
||||
"""
|
||||
Generate word document and assert no tmp files are left in /tmp folder
|
||||
even when the conversion fails.
|
||||
@@ -214,4 +215,5 @@ def test_models_templates__generate_word__raise_error(_mock_send_mail):
|
||||
template.generate_word("<p>Test body</p>", {})
|
||||
except RuntimeError as e:
|
||||
assert str(e) == "Conversion failed"
|
||||
time.sleep(0.5)
|
||||
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
Unit tests for the Invitation model
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
from django.core import mail
|
||||
|
||||
import pytest
|
||||
|
||||
from core.utils import email_invitation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_utils__email_invitation_success():
|
||||
"""
|
||||
The email invitation is sent successfully.
|
||||
"""
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
email_invitation("en", "guest@example.com", "123-456-789")
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == ["guest@example.com"]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Invitation to join Docs!" in email_content
|
||||
assert "docs/123-456-789/" in email_content
|
||||
|
||||
|
||||
def test_utils__email_invitation_success_fr():
|
||||
"""
|
||||
The email invitation is sent successfully in french.
|
||||
"""
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
email_invitation("fr-fr", "guest@example.com", "123-456-789")
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == ["guest@example.com"]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Invitation à rejoindre Docs !" in email_content
|
||||
assert "docs/123-456-789/" in email_content
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.utils.send_mail",
|
||||
side_effect=smtplib.SMTPException("Error SMTPException"),
|
||||
)
|
||||
@mock.patch.object(Logger, "error")
|
||||
def test_utils__email_invitation_failed(mock_logger, _mock_send_mail):
|
||||
"""Check mail behavior when an SMTP error occurs when sent an email invitation."""
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
email_invitation("en", "guest@example.com", "123-456-789")
|
||||
|
||||
# No email has been sent
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
# Logger should be called
|
||||
mock_logger.assert_called_once()
|
||||
|
||||
(
|
||||
_,
|
||||
email,
|
||||
exception,
|
||||
) = mock_logger.call_args.args
|
||||
|
||||
assert email == "guest@example.com"
|
||||
assert isinstance(exception, smtplib.SMTPException)
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
Utilities for the core app.
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import override
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
def email_invitation(language, email, document_id):
|
||||
"""Send email invitation."""
|
||||
try:
|
||||
with override(language):
|
||||
title = _("Invitation to join Docs!")
|
||||
template_vars = {
|
||||
"title": title,
|
||||
"site": Site.objects.get_current(),
|
||||
"document_id": document_id,
|
||||
}
|
||||
msg_html = render_to_string("mail/html/invitation.html", template_vars)
|
||||
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
|
||||
send_mail(
|
||||
title,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", email, exception)
|
||||
@@ -2,6 +2,7 @@
|
||||
"""create_demo management command"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from collections import defaultdict
|
||||
@@ -111,7 +112,11 @@ def create_demo(stdout):
|
||||
queue = BulkQueue(stdout)
|
||||
|
||||
with Timeit(stdout, "Creating users"):
|
||||
name_size = int(math.sqrt(defaults.NB_OBJECTS["users"]))
|
||||
first_names = [fake.first_name() for _ in range(name_size)]
|
||||
last_names = [fake.last_name() for _ in range(name_size)]
|
||||
for i in range(defaults.NB_OBJECTS["users"]):
|
||||
first_name = random.choice(first_names)
|
||||
queue.push(
|
||||
models.User(
|
||||
admin_email=f"user{i:d}@example.com",
|
||||
@@ -120,6 +125,8 @@ def create_demo(stdout):
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
short_name=first_name,
|
||||
full_name=f"{first_name:s} {random.choice(last_names):s}",
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -384,10 +384,27 @@ class Base(Configuration):
|
||||
OIDC_STORE_ID_TOKEN = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
|
||||
)
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
|
||||
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
|
||||
default=["first_name", "last_name"],
|
||||
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
|
||||
default="first_name",
|
||||
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -546,6 +563,14 @@ class Production(Base):
|
||||
# In other cases, you should comment the following line to avoid security issues.
|
||||
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_HSTS_SECONDS = 60
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_REDIRECT_EXEMPT = [
|
||||
"^__lbheartbeat__",
|
||||
"^__heartbeat__",
|
||||
]
|
||||
|
||||
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
Binary file not shown.
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-08-14 12:43+0000\n"
|
||||
"PO-Revision-Date: 2024-08-14 12:48\n"
|
||||
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
|
||||
"PO-Revision-Date: 2024-09-25 10:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -17,547 +17,330 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 8\n"
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
|
||||
#: build/lib/build/lib/build/lib/core/admin.py:31
|
||||
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
|
||||
#: core/admin.py:31
|
||||
#: core/admin.py:32
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
|
||||
#: build/lib/build/lib/build/lib/core/admin.py:33
|
||||
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
|
||||
#: core/admin.py:33
|
||||
#: core/admin.py:34
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
|
||||
#: build/lib/build/lib/build/lib/core/admin.py:45
|
||||
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
|
||||
#: core/admin.py:45
|
||||
#: core/admin.py:46
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
|
||||
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
|
||||
#: build/lib/build/lib/core/api/serializers.py:176
|
||||
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
|
||||
#: core/api/serializers.py:253
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
|
||||
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
|
||||
#: build/lib/build/lib/core/api/serializers.py:179
|
||||
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
|
||||
#: core/api/serializers.py:256
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
|
||||
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
|
||||
#: build/lib/build/lib/core/authentication/backends.py:71
|
||||
#: build/lib/core/authentication/backends.py:71
|
||||
#: core/authentication/backends.py:71
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
|
||||
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
|
||||
#: build/lib/build/lib/core/authentication/backends.py:91
|
||||
#: build/lib/core/authentication/backends.py:91
|
||||
#: core/authentication/backends.py:91
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
|
||||
#: build/lib/build/lib/build/lib/core/models.py:60
|
||||
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
|
||||
#: core/models.py:61
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
|
||||
#: build/lib/build/lib/build/lib/core/models.py:61
|
||||
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
|
||||
#: core/models.py:62
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
|
||||
#: build/lib/build/lib/build/lib/core/models.py:62
|
||||
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
|
||||
#: core/models.py:63
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
|
||||
#: build/lib/build/lib/build/lib/core/models.py:63
|
||||
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
|
||||
#: core/models.py:64
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
|
||||
#: build/lib/build/lib/build/lib/core/models.py:75
|
||||
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
|
||||
#: core/models.py:76
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
|
||||
#: build/lib/build/lib/build/lib/core/models.py:76
|
||||
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
|
||||
#: core/models.py:77
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
|
||||
#: build/lib/build/lib/build/lib/core/models.py:82
|
||||
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
|
||||
#: core/models.py:83
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
|
||||
#: build/lib/build/lib/build/lib/core/models.py:83
|
||||
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
|
||||
#: core/models.py:84
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
|
||||
#: build/lib/build/lib/build/lib/core/models.py:88
|
||||
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
|
||||
#: core/models.py:89
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
|
||||
#: build/lib/build/lib/build/lib/core/models.py:89
|
||||
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
|
||||
#: core/models.py:90
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
|
||||
#: build/lib/build/lib/build/lib/core/models.py:109
|
||||
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
|
||||
#: core/models.py:110
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
|
||||
#: build/lib/build/lib/build/lib/core/models.py:115
|
||||
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
|
||||
#: core/models.py:116
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
|
||||
#: build/lib/build/lib/build/lib/core/models.py:117
|
||||
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
|
||||
#: core/models.py:118
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
|
||||
#: build/lib/build/lib/build/lib/core/models.py:125
|
||||
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
|
||||
#: core/models.py:126
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
|
||||
#: build/lib/build/lib/build/lib/core/models.py:130
|
||||
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
|
||||
#: core/models.py:131
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
|
||||
#: build/lib/build/lib/build/lib/core/models.py:137
|
||||
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
|
||||
#: core/models.py:138
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
|
||||
#: build/lib/build/lib/build/lib/core/models.py:138
|
||||
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
|
||||
#: core/models.py:139
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
|
||||
#: build/lib/build/lib/build/lib/core/models.py:144
|
||||
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
|
||||
#: core/models.py:145
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
|
||||
#: build/lib/build/lib/build/lib/core/models.py:147
|
||||
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
|
||||
#: core/models.py:148
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
|
||||
#: build/lib/build/lib/build/lib/core/models.py:149
|
||||
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
|
||||
#: core/models.py:150
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
|
||||
#: build/lib/build/lib/build/lib/core/models.py:152
|
||||
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
|
||||
#: core/models.py:153
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
|
||||
#: build/lib/build/lib/build/lib/core/models.py:154
|
||||
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
|
||||
#: core/models.py:155
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
|
||||
#: build/lib/build/lib/build/lib/core/models.py:157
|
||||
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
|
||||
#: core/models.py:158
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
|
||||
#: build/lib/build/lib/build/lib/core/models.py:160
|
||||
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
|
||||
#: core/models.py:161
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
|
||||
#: build/lib/build/lib/build/lib/core/models.py:172
|
||||
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
|
||||
#: core/models.py:173
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
|
||||
#: build/lib/build/lib/build/lib/core/models.py:173
|
||||
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
|
||||
#: core/models.py:174
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
|
||||
#: build/lib/build/lib/build/lib/core/models.py:304
|
||||
#: build/lib/build/lib/build/lib/core/models.py:531
|
||||
#: build/lib/build/lib/core/models.py:304
|
||||
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
|
||||
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
|
||||
#: build/lib/build/lib/build/lib/core/models.py:306
|
||||
#: build/lib/build/lib/build/lib/core/models.py:536
|
||||
#: build/lib/build/lib/core/models.py:306
|
||||
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
|
||||
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
|
||||
#: build/lib/build/lib/build/lib/core/models.py:308
|
||||
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
|
||||
#: core/models.py:309
|
||||
msgid "Whether this document is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
|
||||
#: build/lib/build/lib/build/lib/core/models.py:316
|
||||
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
|
||||
#: core/models.py:317
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
|
||||
#: build/lib/build/lib/build/lib/core/models.py:317
|
||||
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
|
||||
#: core/models.py:318
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
|
||||
#: build/lib/build/lib/build/lib/core/models.py:495
|
||||
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
|
||||
#: core/models.py:496
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
|
||||
#: build/lib/build/lib/build/lib/core/models.py:496
|
||||
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
|
||||
#: core/models.py:497
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
|
||||
#: build/lib/build/lib/build/lib/core/models.py:502
|
||||
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
|
||||
#: core/models.py:503
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
|
||||
#: build/lib/build/lib/build/lib/core/models.py:508
|
||||
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
|
||||
#: core/models.py:509
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
|
||||
#: build/lib/build/lib/build/lib/core/models.py:514
|
||||
#: build/lib/build/lib/build/lib/core/models.py:691
|
||||
#: build/lib/build/lib/core/models.py:514
|
||||
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
|
||||
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
|
||||
#: build/lib/build/lib/build/lib/core/models.py:532
|
||||
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
|
||||
#: core/models.py:533
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
|
||||
#: build/lib/build/lib/build/lib/core/models.py:533
|
||||
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
|
||||
#: core/models.py:534
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
|
||||
#: build/lib/build/lib/build/lib/core/models.py:534
|
||||
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
|
||||
#: core/models.py:535
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
|
||||
#: build/lib/build/lib/build/lib/core/models.py:538
|
||||
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
|
||||
#: core/models.py:539
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
|
||||
#: build/lib/build/lib/build/lib/core/models.py:544
|
||||
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
|
||||
#: core/models.py:545
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
|
||||
#: build/lib/build/lib/build/lib/core/models.py:545
|
||||
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
|
||||
#: core/models.py:546
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
|
||||
#: build/lib/build/lib/build/lib/core/models.py:672
|
||||
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
|
||||
#: core/models.py:685
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
|
||||
#: build/lib/build/lib/build/lib/core/models.py:673
|
||||
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
|
||||
#: core/models.py:686
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
|
||||
#: build/lib/build/lib/build/lib/core/models.py:679
|
||||
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
|
||||
#: core/models.py:692
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
|
||||
#: build/lib/build/lib/build/lib/core/models.py:685
|
||||
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
|
||||
#: core/models.py:698
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
|
||||
#: build/lib/build/lib/build/lib/core/models.py:708
|
||||
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
|
||||
#: core/models.py:721
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
|
||||
#: build/lib/build/lib/build/lib/core/models.py:725
|
||||
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
|
||||
#: core/models.py:738
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
|
||||
#: build/lib/build/lib/build/lib/core/models.py:726
|
||||
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
|
||||
#: core/models.py:739
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
|
||||
#: build/lib/build/lib/build/lib/core/models.py:751
|
||||
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
|
||||
#: core/models.py:764
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
|
||||
#: build/lib/build/lib/build/lib/core/models.py:795
|
||||
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
|
||||
msgid "Invitation to join Impress!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
|
||||
#: build/lib/build/lib/build/lib/impress/settings.py:158
|
||||
#: build/lib/build/lib/impress/settings.py:158
|
||||
#: build/lib/impress/settings.py:158 impress/settings.py:158
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
|
||||
#: build/lib/build/lib/build/lib/impress/settings.py:159
|
||||
#: build/lib/build/lib/impress/settings.py:159
|
||||
#: build/lib/impress/settings.py:159 impress/settings.py:159
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/core/api/serializers.py:185
|
||||
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
|
||||
#: core/api/serializers.py:262
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:808
|
||||
msgid "Invitation to join Docs!"
|
||||
#: core/authentication/backends.py:56
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
#: core/authentication/backends.py:81
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#: core/authentication/backends.py:101
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:63 core/models.py:70
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:71
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:72
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:80
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:84
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:86
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:98
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:99
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:105
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:106
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:111
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:112
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:132
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:140
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:148
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:153
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:161
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:167
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:170
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:175
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:177
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:180
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:195
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:196
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:328 core/models.py:644
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:343
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:344
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:347
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:537
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgid "%(username)s shared a document with you: %(document)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
#: core/models.py:580
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
#: core/models.py:581
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
#: core/models.py:587
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:608
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:609
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:615
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:621
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:627 core/models.py:816
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:645
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:646
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:647
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:649
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:651
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:657
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:658
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:797
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:798
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:804
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:810
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:833
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:850
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:851
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:868
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/templates/mail/html/invitation2.html:160
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/invitation2.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:190
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "Invitation to join a document !"
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
#, python-format
|
||||
msgid " %(username)s shared a document with you ! "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:198
|
||||
msgid "Welcome to <strong>Docs!</strong>"
|
||||
#: core/templates/mail/html/invitation.html:197
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
#, python-format
|
||||
msgid " %(username)s invited you as an %(role)s on the following document : "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:213
|
||||
#: core/templates/mail/text/invitation.txt:12
|
||||
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:218
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
|
||||
#: core/templates/mail/html/invitation.html:206
|
||||
#: core/templates/mail/html/invitation2.html:211
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
#: core/templates/mail/text/invitation2.txt:11
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:223
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid "With Docs, you will be able to:"
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:224
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid "Create documents."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:225
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/html/invitation2.html:235
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "Work offline."
|
||||
#: core/templates/mail/text/invitation2.txt:17
|
||||
msgid "Brought to you by La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Invite members of your community to your document in just a few clicks."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Visit Docs"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:246
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:251
|
||||
#: core/templates/mail/text/invitation.txt:22
|
||||
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:256
|
||||
#: core/templates/mail/text/invitation.txt:23
|
||||
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:263
|
||||
#: core/templates/mail/text/invitation.txt:25
|
||||
msgid "Sincerely,"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:264
|
||||
#: core/templates/mail/text/invitation.txt:27
|
||||
msgid "The La Suite Numérique Team"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#: core/templates/mail/html/invitation2.html:190
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgid "%(username)s shared a document with you"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "Welcome to Docs!"
|
||||
#: core/templates/mail/html/invitation2.html:197
|
||||
#: core/templates/mail/text/invitation2.txt:8
|
||||
#, python-format
|
||||
msgid "%(username)s invited you as an %(role)s on the following document :"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation2.html:228
|
||||
#: core/templates/mail/text/invitation2.txt:15
|
||||
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:176
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:177
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-08-14 12:43+0000\n"
|
||||
"PO-Revision-Date: 2024-08-14 12:48\n"
|
||||
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
|
||||
"PO-Revision-Date: 2024-09-25 10:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -17,547 +17,330 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 8\n"
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
|
||||
#: build/lib/build/lib/build/lib/core/admin.py:31
|
||||
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
|
||||
#: core/admin.py:31
|
||||
#: core/admin.py:32
|
||||
msgid "Personal info"
|
||||
msgstr "Infos Personnelles"
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
|
||||
#: build/lib/build/lib/build/lib/core/admin.py:33
|
||||
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
|
||||
#: core/admin.py:33
|
||||
#: core/admin.py:34
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
|
||||
#: build/lib/build/lib/build/lib/core/admin.py:45
|
||||
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
|
||||
#: core/admin.py:45
|
||||
#: core/admin.py:46
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
|
||||
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
|
||||
#: build/lib/build/lib/core/api/serializers.py:176
|
||||
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
|
||||
#: core/api/serializers.py:253
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
|
||||
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
|
||||
#: build/lib/build/lib/core/api/serializers.py:179
|
||||
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
|
||||
#: core/api/serializers.py:256
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
|
||||
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
|
||||
#: build/lib/build/lib/core/authentication/backends.py:71
|
||||
#: build/lib/core/authentication/backends.py:71
|
||||
#: core/authentication/backends.py:71
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
|
||||
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
|
||||
#: build/lib/build/lib/core/authentication/backends.py:91
|
||||
#: build/lib/core/authentication/backends.py:91
|
||||
#: core/authentication/backends.py:91
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
|
||||
#: build/lib/build/lib/build/lib/core/models.py:60
|
||||
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
|
||||
#: core/models.py:61
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
|
||||
#: build/lib/build/lib/build/lib/core/models.py:61
|
||||
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
|
||||
#: core/models.py:62
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
|
||||
#: build/lib/build/lib/build/lib/core/models.py:62
|
||||
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
|
||||
#: core/models.py:63
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
|
||||
#: build/lib/build/lib/build/lib/core/models.py:63
|
||||
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
|
||||
#: core/models.py:64
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
|
||||
#: build/lib/build/lib/build/lib/core/models.py:75
|
||||
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
|
||||
#: core/models.py:76
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
|
||||
#: build/lib/build/lib/build/lib/core/models.py:76
|
||||
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
|
||||
#: core/models.py:77
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
|
||||
#: build/lib/build/lib/build/lib/core/models.py:82
|
||||
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
|
||||
#: core/models.py:83
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
|
||||
#: build/lib/build/lib/build/lib/core/models.py:83
|
||||
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
|
||||
#: core/models.py:84
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
|
||||
#: build/lib/build/lib/build/lib/core/models.py:88
|
||||
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
|
||||
#: core/models.py:89
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
|
||||
#: build/lib/build/lib/build/lib/core/models.py:89
|
||||
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
|
||||
#: core/models.py:90
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
|
||||
#: build/lib/build/lib/build/lib/core/models.py:109
|
||||
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
|
||||
#: core/models.py:110
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
|
||||
#: build/lib/build/lib/build/lib/core/models.py:115
|
||||
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
|
||||
#: core/models.py:116
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
|
||||
#: build/lib/build/lib/build/lib/core/models.py:117
|
||||
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
|
||||
#: core/models.py:118
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
|
||||
#: build/lib/build/lib/build/lib/core/models.py:125
|
||||
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
|
||||
#: core/models.py:126
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
|
||||
#: build/lib/build/lib/build/lib/core/models.py:130
|
||||
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
|
||||
#: core/models.py:131
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
|
||||
#: build/lib/build/lib/build/lib/core/models.py:137
|
||||
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
|
||||
#: core/models.py:138
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
|
||||
#: build/lib/build/lib/build/lib/core/models.py:138
|
||||
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
|
||||
#: core/models.py:139
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
|
||||
#: build/lib/build/lib/build/lib/core/models.py:144
|
||||
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
|
||||
#: core/models.py:145
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
|
||||
#: build/lib/build/lib/build/lib/core/models.py:147
|
||||
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
|
||||
#: core/models.py:148
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
|
||||
#: build/lib/build/lib/build/lib/core/models.py:149
|
||||
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
|
||||
#: core/models.py:150
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
|
||||
#: build/lib/build/lib/build/lib/core/models.py:152
|
||||
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
|
||||
#: core/models.py:153
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
|
||||
#: build/lib/build/lib/build/lib/core/models.py:154
|
||||
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
|
||||
#: core/models.py:155
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
|
||||
#: build/lib/build/lib/build/lib/core/models.py:157
|
||||
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
|
||||
#: core/models.py:158
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
|
||||
#: build/lib/build/lib/build/lib/core/models.py:160
|
||||
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
|
||||
#: core/models.py:161
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
|
||||
#: build/lib/build/lib/build/lib/core/models.py:172
|
||||
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
|
||||
#: core/models.py:173
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
|
||||
#: build/lib/build/lib/build/lib/core/models.py:173
|
||||
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
|
||||
#: core/models.py:174
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
|
||||
#: build/lib/build/lib/build/lib/core/models.py:304
|
||||
#: build/lib/build/lib/build/lib/core/models.py:531
|
||||
#: build/lib/build/lib/core/models.py:304
|
||||
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
|
||||
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
|
||||
#: build/lib/build/lib/build/lib/core/models.py:306
|
||||
#: build/lib/build/lib/build/lib/core/models.py:536
|
||||
#: build/lib/build/lib/core/models.py:306
|
||||
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
|
||||
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
|
||||
#: build/lib/build/lib/build/lib/core/models.py:308
|
||||
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
|
||||
#: core/models.py:309
|
||||
msgid "Whether this document is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
|
||||
#: build/lib/build/lib/build/lib/core/models.py:316
|
||||
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
|
||||
#: core/models.py:317
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
|
||||
#: build/lib/build/lib/build/lib/core/models.py:317
|
||||
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
|
||||
#: core/models.py:318
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
|
||||
#: build/lib/build/lib/build/lib/core/models.py:495
|
||||
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
|
||||
#: core/models.py:496
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
|
||||
#: build/lib/build/lib/build/lib/core/models.py:496
|
||||
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
|
||||
#: core/models.py:497
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
|
||||
#: build/lib/build/lib/build/lib/core/models.py:502
|
||||
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
|
||||
#: core/models.py:503
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
|
||||
#: build/lib/build/lib/build/lib/core/models.py:508
|
||||
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
|
||||
#: core/models.py:509
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
|
||||
#: build/lib/build/lib/build/lib/core/models.py:514
|
||||
#: build/lib/build/lib/build/lib/core/models.py:691
|
||||
#: build/lib/build/lib/core/models.py:514
|
||||
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
|
||||
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
|
||||
#: build/lib/build/lib/build/lib/core/models.py:532
|
||||
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
|
||||
#: core/models.py:533
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
|
||||
#: build/lib/build/lib/build/lib/core/models.py:533
|
||||
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
|
||||
#: core/models.py:534
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
|
||||
#: build/lib/build/lib/build/lib/core/models.py:534
|
||||
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
|
||||
#: core/models.py:535
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
|
||||
#: build/lib/build/lib/build/lib/core/models.py:538
|
||||
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
|
||||
#: core/models.py:539
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
|
||||
#: build/lib/build/lib/build/lib/core/models.py:544
|
||||
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
|
||||
#: core/models.py:545
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
|
||||
#: build/lib/build/lib/build/lib/core/models.py:545
|
||||
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
|
||||
#: core/models.py:546
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
|
||||
#: build/lib/build/lib/build/lib/core/models.py:672
|
||||
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
|
||||
#: core/models.py:685
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
|
||||
#: build/lib/build/lib/build/lib/core/models.py:673
|
||||
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
|
||||
#: core/models.py:686
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
|
||||
#: build/lib/build/lib/build/lib/core/models.py:679
|
||||
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
|
||||
#: core/models.py:692
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
|
||||
#: build/lib/build/lib/build/lib/core/models.py:685
|
||||
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
|
||||
#: core/models.py:698
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
|
||||
#: build/lib/build/lib/build/lib/core/models.py:708
|
||||
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
|
||||
#: core/models.py:721
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
|
||||
#: build/lib/build/lib/build/lib/core/models.py:725
|
||||
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
|
||||
#: core/models.py:738
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
|
||||
#: build/lib/build/lib/build/lib/core/models.py:726
|
||||
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
|
||||
#: core/models.py:739
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
|
||||
#: build/lib/build/lib/build/lib/core/models.py:751
|
||||
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
|
||||
#: core/models.py:764
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
|
||||
#: build/lib/build/lib/build/lib/core/models.py:795
|
||||
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
|
||||
msgid "Invitation to join Impress!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
|
||||
#: build/lib/build/lib/build/lib/impress/settings.py:158
|
||||
#: build/lib/build/lib/impress/settings.py:158
|
||||
#: build/lib/impress/settings.py:158 impress/settings.py:158
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
|
||||
#: build/lib/build/lib/build/lib/impress/settings.py:159
|
||||
#: build/lib/build/lib/impress/settings.py:159
|
||||
#: build/lib/impress/settings.py:159 impress/settings.py:159
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/build/lib/core/api/serializers.py:185
|
||||
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
|
||||
#: core/api/serializers.py:262
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:808
|
||||
msgid "Invitation to join Docs!"
|
||||
msgstr "Invitation à rejoindre Docs !"
|
||||
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
#: core/authentication/backends.py:56
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#: core/authentication/backends.py:81
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:101
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
msgid "Reader"
|
||||
msgstr "Lecteur"
|
||||
|
||||
#: core/models.py:63 core/models.py:70
|
||||
msgid "Editor"
|
||||
msgstr "Éditeur"
|
||||
|
||||
#: core/models.py:71
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: core/models.py:72
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:80
|
||||
msgid "Restricted"
|
||||
msgstr "Restreint"
|
||||
|
||||
#: core/models.py:84
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifié"
|
||||
|
||||
#: core/models.py:86
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: core/models.py:98
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:99
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:105
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:106
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:111
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:112
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:132
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:140
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:148
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:153
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:161
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:167
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:170
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:175
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:177
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:180
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:195
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:196
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:328 core/models.py:644
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:343
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:344
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:347
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:537
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgid "%(username)s shared a document with you: %(document)s"
|
||||
msgstr "%(username)s a partagé un document avec vous: %(document)s"
|
||||
|
||||
#: core/models.py:580
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
#: core/models.py:581
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
#: core/models.py:587
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
#: core/models.py:608
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:609
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:615
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:621
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:627 core/models.py:816
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:645
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:646
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:647
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:649
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:651
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:657
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:658
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:797
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:798
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:804
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:810
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:833
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:850
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:851
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:868
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/templates/mail/html/invitation2.html:160
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/invitation2.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:190
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "Invitation to join a document !"
|
||||
msgstr "Invitation à rejoindre un document !"
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
#, python-format
|
||||
msgid " %(username)s shared a document with you ! "
|
||||
msgstr " %(username)s a partagé un document avec vous ! "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:198
|
||||
msgid "Welcome to <strong>Docs!</strong>"
|
||||
msgstr "Bienvenue sur <strong>Docs !</strong>"
|
||||
#: core/templates/mail/html/invitation.html:197
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
#, python-format
|
||||
msgid " %(username)s invited you as an %(role)s on the following document : "
|
||||
msgstr " %(username)s vous a invité en tant que %(role)s sur le document suivant : "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:213
|
||||
#: core/templates/mail/text/invitation.txt:12
|
||||
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
|
||||
msgstr "Nous sommes heureux de vous accueillir dans notre communauté sur Docs, votre nouveau compagnon pour collaborer sur des documents efficacement, intuitivement, et en toute sécurité."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:218
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
|
||||
msgstr "Notre application est conçue pour vous aider à organiser, collaborer et gérer vos permissions."
|
||||
#: core/templates/mail/html/invitation.html:206
|
||||
#: core/templates/mail/html/invitation2.html:211
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
#: core/templates/mail/text/invitation2.txt:11
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:223
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid "With Docs, you will be able to:"
|
||||
msgstr "Avec Docs, vous serez capable de :"
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
|
||||
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:224
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid "Create documents."
|
||||
msgstr "Créez des documents."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:225
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/html/invitation2.html:235
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "Work offline."
|
||||
msgstr "Travailler hors ligne."
|
||||
#: core/templates/mail/text/invitation2.txt:17
|
||||
msgid "Brought to you by La Suite Numérique"
|
||||
msgstr "Proposé par La Suite Numérique"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Invite members of your community to your document in just a few clicks."
|
||||
msgstr "Invitez des membres de votre communauté sur votre document en quelques clics."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Visit Docs"
|
||||
msgstr "Visitez Docs"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:246
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
|
||||
msgstr "Nous sommes persuadés que Docs vous aidera à améliorer votre efficacité et votre productivité tout en renforçant les liens entre vos membres."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:251
|
||||
#: core/templates/mail/text/invitation.txt:22
|
||||
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
|
||||
msgstr "N'hésitez pas à explorer toutes les fonctionnalités de l'application et à nous faire part de vos commentaires et suggestions. Vos commentaires nous sont précieux et nous permettront d'améliorer continuellement notre service."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:256
|
||||
#: core/templates/mail/text/invitation.txt:23
|
||||
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
|
||||
msgstr "Encore une fois, bienvenue à bord ! Nous sommes impatients de vous accompagner dans votre aventure collaborative."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:263
|
||||
#: core/templates/mail/text/invitation.txt:25
|
||||
msgid "Sincerely,"
|
||||
msgstr "Sincèrement,"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:264
|
||||
#: core/templates/mail/text/invitation.txt:27
|
||||
msgid "The La Suite Numérique Team"
|
||||
msgstr "L'équipe La Suite Numérique"
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#: core/templates/mail/html/invitation2.html:190
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgid "%(username)s shared a document with you"
|
||||
msgstr "%(username)s a partagé un document avec vous"
|
||||
|
||||
#: core/templates/mail/html/invitation2.html:197
|
||||
#: core/templates/mail/text/invitation2.txt:8
|
||||
#, python-format
|
||||
msgid "%(username)s invited you as an %(role)s on the following document :"
|
||||
msgstr "%(username)s vous a invité en tant que %(role)s sur le document suivant :"
|
||||
|
||||
#: core/templates/mail/html/invitation2.html:228
|
||||
#: core/templates/mail/text/invitation2.txt:15
|
||||
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
|
||||
msgstr "Docs, votre nouvel outil essentiel pour organiser, partager et collaborer sur votre document en équipe."
|
||||
|
||||
#: impress/settings.py:176
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "Welcome to Docs!"
|
||||
msgstr "Bienvenue sur Docs !"
|
||||
#: impress/settings.py:177
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "1.3.0"
|
||||
version = "1.5.1"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,38 +25,39 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.35.10",
|
||||
"boto3==1.35.34",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.4.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.0.8",
|
||||
"redis==5.1.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1",
|
||||
"django==5.1.1",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.27.2",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.9",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
"freezegun==1.5.1",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.23.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.1",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"PyJWT==2.9.0",
|
||||
"pypandoc==1.13",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.13.0",
|
||||
"sentry-sdk==2.15.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.7.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"y-py==0.6.2"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -70,18 +71,18 @@ dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.27.0",
|
||||
"ipython==8.28.0",
|
||||
"pyfakefs==5.6.0",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.2.7",
|
||||
"pylint==3.3.1",
|
||||
"pytest-cov==5.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.2",
|
||||
"pytest==8.3.3",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.6.3",
|
||||
"types-requests==2.32.0.20240712",
|
||||
"ruff==0.6.9",
|
||||
"types-requests==2.32.0.20240914",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine as frontend-deps-y-provider
|
||||
FROM node:20-alpine AS frontend-deps-y-provider
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
@@ -15,7 +15,7 @@ COPY ./src/frontend/ .
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
# ---- y-provider ----
|
||||
FROM frontend-deps-y-provider as y-provider
|
||||
FROM frontend-deps-y-provider AS y-provider
|
||||
|
||||
WORKDIR /home/frontend/servers/y-provider
|
||||
RUN yarn build
|
||||
@@ -28,7 +28,7 @@ ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
FROM node:20-alpine as frontend-deps
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
@@ -43,11 +43,11 @@ COPY .dockerignore ./.dockerignore
|
||||
COPY ./src/frontend/ .
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-deps as impress
|
||||
FROM frontend-deps AS impress
|
||||
|
||||
WORKDIR /home/frontend/apps/impress
|
||||
|
||||
FROM frontend-deps as impress-dev
|
||||
FROM frontend-deps AS impress-dev
|
||||
|
||||
WORKDIR /home/frontend/apps/impress
|
||||
|
||||
@@ -57,7 +57,7 @@ CMD [ "yarn", "dev"]
|
||||
|
||||
# Tilt will rebuild impress target so, we dissociate impress and impress-builder
|
||||
# to avoid rebuilding the app at every changes.
|
||||
FROM impress as impress-builder
|
||||
FROM impress AS impress-builder
|
||||
|
||||
WORKDIR /home/frontend/apps/impress
|
||||
|
||||
@@ -70,10 +70,16 @@ ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
|
||||
ARG API_ORIGIN
|
||||
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
|
||||
|
||||
ARG MEDIA_URL
|
||||
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
|
||||
|
||||
ARG SW_DEACTIVATED
|
||||
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export const keyCloakSignIn = async (page: Page, browserName: string) => {
|
||||
const title = await page.locator('h1').first().textContent({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const login = `user-e2e-${browserName}`;
|
||||
const password = `password-e2e-${browserName}`;
|
||||
|
||||
@@ -12,7 +8,7 @@ export const keyCloakSignIn = async (page: Page, browserName: string) => {
|
||||
await page.getByRole('textbox', { name: 'password' }).fill(password);
|
||||
|
||||
await page.click('input[type="submit"]', { force: true });
|
||||
} else if (title?.includes('Sign in to your account')) {
|
||||
} else {
|
||||
await page.getByRole('textbox', { name: 'username' }).fill(login);
|
||||
|
||||
await page.getByRole('textbox', { name: 'password' }).fill(password);
|
||||
@@ -33,32 +29,21 @@ export const createDoc = async (
|
||||
length: number,
|
||||
isPublic: boolean = false,
|
||||
) => {
|
||||
const buttonCreate = page.getByRole('button', {
|
||||
name: 'Create the document',
|
||||
});
|
||||
|
||||
const randomDocs = randomName(docName, browserName, length);
|
||||
|
||||
for (let i = 0; i < randomDocs.length; i++) {
|
||||
const header = page.locator('header').first();
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
|
||||
const buttonCreateHomepage = page.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
});
|
||||
await buttonCreateHomepage.click();
|
||||
|
||||
// Fill input
|
||||
await page
|
||||
.getByRole('textbox', {
|
||||
name: 'Document name',
|
||||
.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
})
|
||||
.fill(randomDocs[i]);
|
||||
.click();
|
||||
|
||||
await expect(buttonCreate).toBeEnabled();
|
||||
await buttonCreate.click();
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDocs[i])).toBeVisible();
|
||||
await page.getByRole('heading', { name: 'Untitled document' }).click();
|
||||
await page.keyboard.type(randomDocs[i]);
|
||||
await page.getByText('Created at ').click();
|
||||
|
||||
if (isPublic) {
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
@@ -128,13 +113,14 @@ export const goToGridDoc = async (
|
||||
const header = page.locator('header').first();
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
|
||||
const datagrid = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
const datagrid = page.getByLabel('Datagrid of the documents page 1');
|
||||
const datagridTable = datagrid.getByRole('table');
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const rows = datagrid.getByRole('row');
|
||||
const rows = datagridTable.getByRole('row');
|
||||
const row = title
|
||||
? rows.filter({
|
||||
hasText: title,
|
||||
@@ -147,7 +133,7 @@ export const goToGridDoc = async (
|
||||
|
||||
expect(docTitle).toBeDefined();
|
||||
|
||||
await docTitleCell.click();
|
||||
await row.getByRole('link').first().click();
|
||||
|
||||
return docTitle as string;
|
||||
};
|
||||
@@ -155,7 +141,13 @@ export const goToGridDoc = async (
|
||||
export const mockedDocument = async (page: Page, json: object) => {
|
||||
await page.route('**/documents/**/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET') && !request.url().includes('page=')) {
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
!request.url().includes('page=') &&
|
||||
!request.url().includes('versions') &&
|
||||
!request.url().includes('accesses') &&
|
||||
!request.url().includes('invitations')
|
||||
) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: 'mocked-document-id',
|
||||
@@ -183,3 +175,82 @@ export const mockedDocument = async (page: Page, json: object) => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
await page.route('**/invitations/**/', async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
request.url().includes('invitations') &&
|
||||
request.url().includes('page=')
|
||||
) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: 1,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: [
|
||||
{
|
||||
id: '120ec765-43af-4602-83eb-7f4e1224548a',
|
||||
abilities: {
|
||||
destroy: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
created_at: '2024-10-03T12:19:26.107687Z',
|
||||
email: 'test@invitation.test',
|
||||
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
|
||||
role: 'editor',
|
||||
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
|
||||
is_expired: false,
|
||||
...json,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const mockedAccesses = async (page: Page, json?: object) => {
|
||||
await page.route('**/accesses/**/', async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
request.url().includes('accesses') &&
|
||||
request.url().includes('page=')
|
||||
) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: 1,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: [
|
||||
{
|
||||
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
|
||||
user: {
|
||||
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
|
||||
email: 'test@accesses.test',
|
||||
},
|
||||
team: '',
|
||||
role: 'reader',
|
||||
abilities: {
|
||||
destroy: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
set_role_to: ['administrator', 'editor'],
|
||||
},
|
||||
...json,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,59 +7,25 @@ test.beforeEach(async ({ page }) => {
|
||||
});
|
||||
|
||||
test.describe('Doc Create', () => {
|
||||
test('checks all the create doc elements are visible', async ({ page }) => {
|
||||
const buttonCreateHomepage = page.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
});
|
||||
await buttonCreateHomepage.click();
|
||||
await expect(buttonCreateHomepage).toBeHidden();
|
||||
|
||||
const card = page.getByRole('dialog').first();
|
||||
|
||||
await expect(
|
||||
card.locator('h2').getByText('Create a new document'),
|
||||
).toBeVisible();
|
||||
await expect(card.getByLabel('Document name')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
card.getByRole('button', {
|
||||
name: 'Create the document',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(card.getByLabel('Close the modal')).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks the cancel button interaction', async ({ page }) => {
|
||||
const buttonCreateHomepage = page.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
});
|
||||
await buttonCreateHomepage.click();
|
||||
await expect(buttonCreateHomepage).toBeHidden();
|
||||
|
||||
const card = page.getByRole('dialog').first();
|
||||
|
||||
await card.getByLabel('Close the modal').click();
|
||||
|
||||
await expect(buttonCreateHomepage).toBeVisible();
|
||||
});
|
||||
|
||||
test('it creates a doc', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
|
||||
|
||||
expect(await page.locator('title').textContent()).toMatch(
|
||||
/My new doc - Docs/,
|
||||
await page.waitForFunction(
|
||||
() => document.title.match(/My new doc - Docs/),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
const header = page.locator('header').first();
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
|
||||
const datagrid = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
const datagrid = page.getByLabel('Datagrid of the documents page 1');
|
||||
const datagridTable = datagrid.getByRole('table');
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
|
||||
|
||||
await expect(datagrid.getByText(docTitle)).toBeVisible();
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(datagridTable.getByText(docTitle)).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,19 +40,18 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page
|
||||
.locator('.ProseMirror.bn-editor')
|
||||
.fill('[test markdown](http://test-markdown.html)');
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('[test markdown](http://test-markdown.html)');
|
||||
|
||||
await expect(page.getByText('[test markdown]')).toBeVisible();
|
||||
await expect(editor.getByText('[test markdown]')).toBeVisible();
|
||||
|
||||
await page.getByText('[test markdown]').dblclick();
|
||||
await editor.getByText('[test markdown]').dblclick();
|
||||
await page.locator('button[data-test="convertMarkdown"]').click();
|
||||
|
||||
await expect(page.getByText('[test markdown]')).toBeHidden();
|
||||
await expect(editor.getByText('[test markdown]')).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('link', {
|
||||
editor.getByRole('link', {
|
||||
name: 'test markdown',
|
||||
}),
|
||||
).toHaveAttribute('href', 'http://test-markdown.html');
|
||||
@@ -64,38 +63,40 @@ test.describe('Doc Editor', () => {
|
||||
// Check the first doc
|
||||
const firstDoc = await goToGridDoc(page);
|
||||
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World Doc 1');
|
||||
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('Hello World Doc 1');
|
||||
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
|
||||
|
||||
// Check the second doc
|
||||
const secondDoc = await goToGridDoc(page, {
|
||||
nthRow: 2,
|
||||
});
|
||||
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
|
||||
await expect(page.getByText('Hello World Doc 1')).toBeHidden();
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World Doc 2');
|
||||
await expect(page.getByText('Hello World Doc 2')).toBeVisible();
|
||||
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
|
||||
await editor.click();
|
||||
await editor.fill('Hello World Doc 2');
|
||||
await expect(editor.getByText('Hello World Doc 2')).toBeVisible();
|
||||
|
||||
// Check the first doc again
|
||||
await goToGridDoc(page, {
|
||||
title: firstDoc,
|
||||
});
|
||||
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
|
||||
await expect(page.getByText('Hello World Doc 2')).toBeHidden();
|
||||
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
|
||||
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
|
||||
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it saves the doc when we change pages', async ({ page }) => {
|
||||
// Check the first doc
|
||||
const doc = await goToGridDoc(page);
|
||||
await expect(page.locator('h2').getByText(doc)).toBeVisible();
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page
|
||||
.locator('.ProseMirror.bn-editor')
|
||||
.fill('Hello World Doc persisted 1');
|
||||
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('Hello World Doc persisted 1');
|
||||
await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible();
|
||||
|
||||
const secondDoc = await goToGridDoc(page, {
|
||||
nthRow: 2,
|
||||
@@ -107,7 +108,7 @@ test.describe('Doc Editor', () => {
|
||||
title: doc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
|
||||
await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it saves the doc when we quit pages', async ({ page, browserName }) => {
|
||||
@@ -117,11 +118,11 @@ test.describe('Doc Editor', () => {
|
||||
// Check the first doc
|
||||
const doc = await goToGridDoc(page);
|
||||
await expect(page.locator('h2').getByText(doc)).toBeVisible();
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page
|
||||
.locator('.ProseMirror.bn-editor')
|
||||
.fill('Hello World Doc persisted 2');
|
||||
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('Hello World Doc persisted 2');
|
||||
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
@@ -129,7 +130,7 @@ test.describe('Doc Editor', () => {
|
||||
title: doc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
|
||||
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it cannot edit if viewer', async ({ page }) => {
|
||||
|
||||
@@ -85,7 +85,7 @@ test.describe('Doc Export', () => {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.slow();
|
||||
test.setTimeout(60000);
|
||||
|
||||
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
let body = '';
|
||||
|
||||
@@ -117,7 +117,9 @@ test.describe('Documents Grid', () => {
|
||||
.getByRole('cell')
|
||||
.nth(cellNumber);
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Initial state
|
||||
await expect(docNameRow1).toHaveText(/.*/);
|
||||
@@ -134,7 +136,9 @@ test.describe('Documents Grid', () => {
|
||||
const responseOrderingAsc = await responsePromiseOrderingAsc;
|
||||
expect(responseOrderingAsc.ok()).toBeTruthy();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await expect(docNameRow1).toHaveText(/.*/);
|
||||
await expect(docNameRow2).toHaveText(/.*/);
|
||||
@@ -155,7 +159,9 @@ test.describe('Documents Grid', () => {
|
||||
const responseOrderingDesc = await responsePromiseOrderingDesc;
|
||||
expect(responseOrderingDesc.ok()).toBeTruthy();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await expect(docNameRow1).toHaveText(/.*/);
|
||||
await expect(docNameRow2).toHaveText(/.*/);
|
||||
@@ -212,26 +218,6 @@ test.describe('Documents Grid', () => {
|
||||
).toHaveText(/.*/);
|
||||
});
|
||||
|
||||
test('it updates document', async ({ page }) => {
|
||||
const datagrid = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
|
||||
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
|
||||
|
||||
const docName = await docRow.nth(1).textContent();
|
||||
|
||||
await docRow.getByLabel('Open the document options').click();
|
||||
|
||||
await page.getByText('Update document').click();
|
||||
|
||||
await page.getByLabel('Document name').fill(`${docName} updated`);
|
||||
|
||||
await page.getByText('Validate the modification').click();
|
||||
|
||||
await expect(datagrid.getByText(`${docName} updated`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('it deletes the document', async ({ page }) => {
|
||||
const datagrid = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
@@ -241,11 +227,9 @@ test.describe('Documents Grid', () => {
|
||||
|
||||
const docName = await docRow.nth(1).textContent();
|
||||
|
||||
await docRow.getByLabel('Open the document options').click();
|
||||
|
||||
await page
|
||||
await docRow
|
||||
.getByRole('button', {
|
||||
name: 'Delete document',
|
||||
name: 'Delete the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -266,3 +250,87 @@ test.describe('Documents Grid', () => {
|
||||
await expect(datagrid.getByText(docName!)).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Documents Grid mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('it checks the grid when mobile', async ({ page }) => {
|
||||
await page.route('**/documents/**', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET') && request.url().includes('page=')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: 1,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: [
|
||||
{
|
||||
id: 'b7fd9d9b-0642-4b4f-8617-ce50f69519ed',
|
||||
title: 'My mocked document',
|
||||
accesses: [
|
||||
{
|
||||
id: '8c1e047a-24e7-4a80-942b-8e9c7ab43e1f',
|
||||
user: {
|
||||
id: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
|
||||
email: 'test@test.test',
|
||||
full_name: 'John Doe',
|
||||
short_name: 'John',
|
||||
},
|
||||
team: '',
|
||||
role: 'owner',
|
||||
abilities: {
|
||||
destroy: false,
|
||||
update: false,
|
||||
partial_update: false,
|
||||
retrieve: true,
|
||||
set_role_to: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
abilities: {
|
||||
attachment_upload: true,
|
||||
destroy: true,
|
||||
link_configuration: true,
|
||||
manage_accesses: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
update: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
},
|
||||
link_role: 'reader',
|
||||
link_reach: 'public',
|
||||
created_at: '2024-10-07T13:02:41.085298Z',
|
||||
updated_at: '2024-10-07T13:30:21.829690Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const datagrid = page.getByLabel('Datagrid of the documents page 1');
|
||||
const tableDatagrid = datagrid.getByRole('table');
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const rows = tableDatagrid.getByRole('row');
|
||||
const row = rows.filter({
|
||||
hasText: 'My mocked document',
|
||||
});
|
||||
|
||||
await expect(row.getByRole('cell').nth(0)).toHaveText('My mocked document');
|
||||
await expect(row.getByRole('cell').nth(1)).toHaveText('Public');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc, mockedDocument } from './common';
|
||||
import {
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
mockedAccesses,
|
||||
mockedDocument,
|
||||
mockedInvitations,
|
||||
} from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -65,49 +71,66 @@ test.describe('Doc Header', () => {
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('it updates the doc', async ({ page, browserName }) => {
|
||||
test('it updates the title doc', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('heading', { name: randomDoc }).fill(' ');
|
||||
await page.getByText('Created at').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Untitled document' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it updates the title doc from editor heading', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Update document',
|
||||
name: 'Create a new document',
|
||||
})
|
||||
.click();
|
||||
|
||||
const docHeader = page.getByLabel(
|
||||
'It is the card information about the document.',
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.locator('h2').getByText(`Update document "${randomDoc}"`),
|
||||
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByText('Document name').fill(`${randomDoc}-updated`);
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Validate the modification',
|
||||
})
|
||||
.click();
|
||||
await editor.locator('h1').click();
|
||||
await page.keyboard.type('Hello World', { delay: 100 });
|
||||
|
||||
await expect(
|
||||
page.getByText('The document has been updated.'),
|
||||
docHeader.getByRole('heading', { name: 'Hello World', level: 2 }),
|
||||
).toBeVisible();
|
||||
|
||||
const docTitle = await goToGridDoc(page, {
|
||||
title: `${randomDoc}-updated`,
|
||||
});
|
||||
await expect(
|
||||
page.getByText('Document title updated successfully'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await docHeader
|
||||
.getByRole('heading', { name: 'Hello World', level: 2 })
|
||||
.fill('Top World');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Update document',
|
||||
})
|
||||
.click();
|
||||
await editor.locator('h1').fill('Super World');
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document name' }),
|
||||
).toHaveValue(`${randomDoc}-updated`);
|
||||
docHeader.getByRole('heading', { name: 'Top World', level: 2 }),
|
||||
).toBeVisible();
|
||||
|
||||
await editor.locator('h1').fill('');
|
||||
|
||||
await docHeader
|
||||
.getByRole('heading', { name: 'Top World', level: 2 })
|
||||
.fill(' ');
|
||||
|
||||
await page.getByText('Created at').click();
|
||||
|
||||
await expect(
|
||||
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it deletes the doc', async ({ page, browserName }) => {
|
||||
@@ -165,21 +188,55 @@ test.describe('Doc Header', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await mockedInvitations(page);
|
||||
await mockedAccesses(page);
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
await expect(
|
||||
page.locator('h2').getByText('Mocked document'),
|
||||
).toHaveAttribute('contenteditable');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Update document' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeHidden();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
|
||||
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
|
||||
await expect(shareModal.getByText('Search by email')).toBeVisible();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
memberCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
test('it checks the options available if editor', async ({ page }) => {
|
||||
@@ -197,20 +254,61 @@ test.describe('Doc Header', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await mockedInvitations(page, {
|
||||
abilities: {
|
||||
destroy: false,
|
||||
update: false,
|
||||
partial_update: false,
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
await mockedAccesses(page);
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
|
||||
await expect(
|
||||
page.locator('h2').getByText('Mocked document'),
|
||||
).toHaveAttribute('contenteditable');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Update document' }),
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeHidden();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
|
||||
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeHidden();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
memberCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -229,21 +327,93 @@ test.describe('Doc Header', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await mockedInvitations(page, {
|
||||
abilities: {
|
||||
destroy: false,
|
||||
update: false,
|
||||
partial_update: false,
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
await mockedAccesses(page);
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
|
||||
await expect(
|
||||
page.locator('h2').getByText('Mocked document'),
|
||||
).not.toHaveAttribute('contenteditable');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Update document' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeHidden();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
|
||||
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeHidden();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
memberCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Documents Header mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('it checks the close button on Share modal', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: true, // Means owner
|
||||
link_configuration: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
await expect(page.getByLabel('Share modal')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
await expect(page.getByLabel('Share modal')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,61 @@ test.describe('Document list members', () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks a big list of invitations', async ({ page }) => {
|
||||
await page.route(
|
||||
/.*\/documents\/.*\/invitations\/\?page=.*/,
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pageId = url.searchParams.get('page');
|
||||
const accesses = {
|
||||
count: 100,
|
||||
next: 'http://anything/?page=2',
|
||||
previous: null,
|
||||
results: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
|
||||
email: `impress@impress.world-page-${pageId}-${i}`,
|
||||
team: '',
|
||||
role: 'editor',
|
||||
abilities: {
|
||||
destroy: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: accesses,
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const list = page.getByLabel('List invitation card').locator('ul');
|
||||
await expect(list.locator('li')).toHaveCount(20);
|
||||
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
|
||||
await page.mouse.wheel(0, 10);
|
||||
|
||||
await waitForElementCount(list.locator('li'), 21, 10000);
|
||||
|
||||
expect(await list.locator('li').count()).toBeGreaterThan(20);
|
||||
await expect(
|
||||
list.getByText(`impress@impress.world-page-1-16`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
list.getByText(`impress@impress.world-page-2-15`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks the role rules', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
|
||||
|
||||
@@ -106,15 +161,17 @@ test.describe('Document list members', () => {
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
await expect(page.getByText('The role has been updated')).toBeVisible();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
|
||||
// Admin still have the right to share
|
||||
await expect(page.locator('h3').getByText('Share')).toBeVisible();
|
||||
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
|
||||
|
||||
await SelectRoleCurrentUser.click();
|
||||
await page.getByRole('option', { name: 'Reader' }).click();
|
||||
await expect(page.getByText('The role has been updated')).toBeVisible();
|
||||
|
||||
// Reader does not have the right to share
|
||||
await expect(page.locator('h3').getByText('Share')).toBeHidden();
|
||||
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('it checks the delete members', async ({ page, browserName }) => {
|
||||
@@ -159,6 +216,8 @@ test.describe('Document list members', () => {
|
||||
page.getByText('The member has been removed from the document').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText('Share')).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Share', level: 3 }),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { keyCloakSignIn } from './common';
|
||||
import { keyCloakSignIn, mockedDocument } from './common';
|
||||
|
||||
test.describe('Doc Routing', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -43,9 +43,12 @@ test.describe('Doc Routing: Not loggued', () => {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await mockedDocument(page, { link_reach: 'public' });
|
||||
await page.goto('/docs/mocked-document-id/');
|
||||
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await keyCloakSignIn(page, browserName);
|
||||
await expect(page).toHaveURL(/\/docs\/mocked-document-id\/$/);
|
||||
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
|
||||
});
|
||||
|
||||
test('The homepage redirects to login.', async ({ page }) => {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Summary', () => {
|
||||
test('it checks the doc summary', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-summary', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Summary',
|
||||
})
|
||||
.click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 1').click();
|
||||
await page.keyboard.type('Hello World');
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 2').click();
|
||||
await page.keyboard.type('Super World');
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 3').click();
|
||||
await page.keyboard.type('Another World');
|
||||
|
||||
await expect(panel.getByText('Hello World')).toBeVisible();
|
||||
await expect(panel.getByText('Super World')).toBeVisible();
|
||||
|
||||
await panel.getByText('Another World').click();
|
||||
|
||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||
|
||||
await panel.getByText('Back to top').click();
|
||||
await expect(editor.getByText('Hello World')).toBeInViewport();
|
||||
|
||||
await panel.getByText('Go to bottom').click();
|
||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Table Content', () => {
|
||||
test('it checks the doc table content', async ({ page, browserName }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-table-content',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Table of contents',
|
||||
})
|
||||
.click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 1').click();
|
||||
await page.keyboard.type('Hello World');
|
||||
await editor.getByText('Hello').dblclick();
|
||||
await page.getByRole('button', { name: 'Strike' }).click();
|
||||
|
||||
await page.locator('.bn-block-outer').first().click();
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 2').click();
|
||||
await page.keyboard.type('Super World', { delay: 100 });
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 3').click();
|
||||
await page.keyboard.type('Another World');
|
||||
|
||||
const hello = panel.getByText('Hello World');
|
||||
const superW = panel.getByText('Super World');
|
||||
const another = panel.getByText('Another World');
|
||||
|
||||
await expect(hello).toBeVisible();
|
||||
await expect(hello).toHaveCSS('font-size', /17/);
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await expect(superW).toBeVisible();
|
||||
await expect(superW).toHaveCSS('font-size', /14/);
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await expect(another).toBeVisible();
|
||||
await expect(another).toHaveCSS('font-size', /12/);
|
||||
await expect(another).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await hello.click();
|
||||
|
||||
await expect(editor.getByText('Hello World')).toBeInViewport();
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await another.click();
|
||||
|
||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'false');
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await panel.getByText('Back to top').click();
|
||||
await expect(editor.getByText('Hello World')).toBeInViewport();
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await panel.getByText('Go to bottom').click();
|
||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('it checks that table contents panel is opened automaticaly if more that 2 headings', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-table-content',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await expect(page.getByLabel('Open the panel')).toBeHidden();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 1').click();
|
||||
await page.keyboard.type('Hello World', { delay: 100 });
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 2').click();
|
||||
await page.keyboard.type('Super World', { delay: 100 });
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByLabel('Close the panel')).toBeVisible();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
await expect(panel.getByText('Hello World')).toBeVisible();
|
||||
await expect(panel.getByText('Super World')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Close the panel').click();
|
||||
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
});
|
||||
207
src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts
Normal file
207
src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc, mockedDocument } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Version', () => {
|
||||
test('it displays the doc versions', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
|
||||
await expect(panel.getByText('Current version')).toBeVisible();
|
||||
expect(await panel.locator('li').count()).toBe(1);
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').last().fill('Hello World');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello World')).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.ProseMirror .bn-block')
|
||||
.getByText('Hello World')
|
||||
.fill('It will create a version');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello World')).toBeHidden();
|
||||
await expect(page.getByText('It will create a version')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(panel.getByText('Current version')).toBeVisible();
|
||||
expect(await panel.locator('li').count()).toBe(2);
|
||||
|
||||
await panel.locator('li').nth(1).click();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit document versions.'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Hello World')).toBeVisible();
|
||||
await expect(page.getByText('It will create a version')).toBeHidden();
|
||||
|
||||
await panel.getByText('Current version').click();
|
||||
await expect(page.getByText('Hello World')).toBeHidden();
|
||||
await expect(page.getByText('It will create a version')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it does not display the doc versions if not allowed', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
versions_list: false,
|
||||
partial_update: true,
|
||||
},
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Version history' }),
|
||||
).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'Table of content' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByLabel('Document panel').getByText('Versions'),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('it restores the doc version', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
await page.locator('.bn-block-outer').last().fill('Hello');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await expect(editor.getByText('Hello')).toBeVisible();
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('World');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('World')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
await panel.locator('li').nth(1).click();
|
||||
await expect(page.getByText('World')).toBeHidden();
|
||||
|
||||
await panel.getByLabel('Open the version options').click();
|
||||
await page.getByText('Restore the version').click();
|
||||
|
||||
await expect(page.getByText('Restore this version?')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Restore',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(panel.locator('li')).toHaveCount(3);
|
||||
|
||||
await panel.getByText('Current version').click();
|
||||
await expect(page.getByText('Hello')).toBeVisible();
|
||||
await expect(page.getByText('World')).toBeHidden();
|
||||
});
|
||||
|
||||
test('it restores the doc version from button title', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.locator('.bn-block-outer').last().click();
|
||||
await editor.locator('.bn-block-outer').last().fill('Hello');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(editor.getByText('Hello')).toBeVisible();
|
||||
await editor.locator('.bn-block-outer').last().click();
|
||||
await page.keyboard.press('Enter');
|
||||
await editor.locator('.bn-block-outer').last().fill('World');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(editor.getByText('World')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
await panel.locator('li').nth(1).click();
|
||||
await expect(editor.getByText('World')).toBeHidden();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Restore this version',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByText('Restore this version?')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Restore',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(panel.locator('li')).toHaveCount(3);
|
||||
|
||||
await panel.getByText('Current version').click();
|
||||
await expect(editor.getByText('Hello')).toBeVisible();
|
||||
await expect(editor.getByText('World')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,9 @@ test.describe('Doc Visibility', () => {
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await expect(datagrid.getByText(docTitle)).toBeVisible();
|
||||
|
||||
@@ -92,5 +94,33 @@ test.describe('Doc Visibility: Not loggued', () => {
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
|
||||
});
|
||||
|
||||
test('A private doc redirect to the OIDC when not authentified.', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.slow();
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
const [docTitle] = await createDoc(page, 'My private doc', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Logout',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { goToGridDoc } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
@@ -45,6 +47,12 @@ test.describe('Footer', () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks footer is not visible on doc editor', async ({ page }) => {
|
||||
await expect(page.locator('footer')).toBeVisible();
|
||||
await goToGridDoc(page);
|
||||
await expect(page.locator('footer')).toBeHidden();
|
||||
});
|
||||
|
||||
const legalPages = [
|
||||
{ name: 'Legal Notice', url: '/legal-notice/' },
|
||||
{ name: 'Personal data and cookies', url: '/personal-data-cookies/' },
|
||||
|
||||
@@ -10,8 +10,6 @@ test.describe('Header', () => {
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
const header = page.locator('header').first();
|
||||
|
||||
await expect(header.getByAltText('Gouvernement Logo')).toBeVisible();
|
||||
|
||||
await expect(header.getByAltText('Docs Logo')).toBeVisible();
|
||||
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
|
||||
'color',
|
||||
@@ -28,7 +26,7 @@ test.describe('Header', () => {
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(header.getByAltText('Language Icon')).toBeVisible();
|
||||
await expect(header.getByText('English')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
header.getByRole('button', {
|
||||
@@ -67,6 +65,42 @@ test.describe('Header', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Header mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('it checks the header when mobile', async ({ page }) => {
|
||||
const header = page.locator('header').first();
|
||||
|
||||
await expect(
|
||||
header.getByRole('button', {
|
||||
name: 'Les services de La Suite numérique',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Logout',
|
||||
}),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(page.getByText('English')).toBeHidden();
|
||||
|
||||
await header.getByLabel('Open the header menu').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Logout',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText('English')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Header: Log out', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ test.describe('Language', () => {
|
||||
).toBeVisible();
|
||||
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('combobox').getByText('EN').click();
|
||||
await header.getByRole('option', { name: 'FR' }).click();
|
||||
await expect(header.getByRole('combobox').getByText('FR')).toBeVisible();
|
||||
await header.getByRole('combobox').getByText('English').click();
|
||||
await header.getByRole('option', { name: 'Français' }).click();
|
||||
await expect(
|
||||
header.getByRole('combobox').getByText('Français'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
@@ -12,7 +12,7 @@
|
||||
"test:ui::chromium": "yarn test:ui --project=chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.47.0",
|
||||
"@playwright/test": "1.47.2",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.4",
|
||||
"eslint-config-impress": "*",
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"convert-stream": "1.0.2",
|
||||
"jsdom": "25.0.0",
|
||||
"jsdom": "25.0.1",
|
||||
"pdf-parse": "1.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=
|
||||
NEXT_PUBLIC_Y_PROVIDER_URL=
|
||||
NEXT_PUBLIC_MEDIA_URL=
|
||||
NEXT_PUBLIC_THEME=dsfr
|
||||
NEXT_PUBLIC_THEME=dsfr
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=
|
||||
|
||||
@@ -189,6 +189,9 @@ const config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'la-gauffre': {
|
||||
activated: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
dsfr: {
|
||||
@@ -321,6 +324,7 @@ const config = {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
@@ -335,6 +339,7 @@ const config = {
|
||||
pagination: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-active': 'var(--c--theme--colors--primary-300)',
|
||||
'border-color': 'var(--c--theme--colors--primary-400)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
@@ -384,6 +389,9 @@ const config = {
|
||||
'forms-textarea': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'la-gauffre': {
|
||||
activated: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -19,22 +19,19 @@
|
||||
"@blocknote/mantine": "*",
|
||||
"@blocknote/react": "*",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hocuspocus/provider": "2.13.5",
|
||||
"@hocuspocus/provider": "2.13.6",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@socialgouv/e2esdk-client": "1.0.0-beta.28",
|
||||
"@socialgouv/e2esdk-devtools": "1.0.0-beta.38",
|
||||
"@socialgouv/e2esdk-react": "1.0.0-beta.28",
|
||||
"@tanstack/react-query": "5.55.4",
|
||||
"@tanstack/react-query": "5.56.2",
|
||||
"i18next": "23.15.1",
|
||||
"idb": "8.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "14.2.9",
|
||||
"next": "14.2.13",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.3.3",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.0.1",
|
||||
"react-select": "5.8.0",
|
||||
"react-i18next": "15.0.2",
|
||||
"react-select": "5.8.1",
|
||||
"styled-components": "6.1.13",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "*",
|
||||
@@ -42,16 +39,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.55.4",
|
||||
"@tanstack/react-query-devtools": "5.58.0",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.5.0",
|
||||
"@testing-library/react": "16.0.1",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/jest": "29.5.13",
|
||||
"@types/lodash": "4.17.9",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.5",
|
||||
"@types/react": "18.3.10",
|
||||
"@types/react-dom": "*",
|
||||
"cross-env": "*",
|
||||
"dotenv": "16.4.5",
|
||||
@@ -65,7 +62,7 @@
|
||||
"stylelint-config-standard": "36.0.1",
|
||||
"stylelint-prettier": "5.0.2",
|
||||
"typescript": "*",
|
||||
"webpack": "5.94.0",
|
||||
"webpack": "5.95.0",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import Page from '../pages';
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter() {
|
||||
return {
|
||||
push: jest.fn(),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Page', () => {
|
||||
it('checks Page rendering', () => {
|
||||
render(<Page />, { wrapper: AppWrapper });
|
||||
|
||||
@@ -10,14 +10,16 @@ import { APIError } from './APIError';
|
||||
import { APIList } from './types';
|
||||
|
||||
export type UseQueryOptionsAPI<Q> = UseQueryOptions<Q, APIError, Q>;
|
||||
export type DefinedInitialDataInfiniteOptionsAPI<Q> =
|
||||
DefinedInitialDataInfiniteOptions<
|
||||
Q,
|
||||
APIError,
|
||||
InfiniteData<Q>,
|
||||
QueryKey,
|
||||
number
|
||||
>;
|
||||
export type DefinedInitialDataInfiniteOptionsAPI<
|
||||
Q,
|
||||
TPageParam = number,
|
||||
> = DefinedInitialDataInfiniteOptions<
|
||||
Q,
|
||||
APIError,
|
||||
InfiniteData<Q>,
|
||||
QueryKey,
|
||||
TPageParam
|
||||
>;
|
||||
|
||||
/**
|
||||
* @param param Used for infinite scroll pagination
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,77 +1,101 @@
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Thin.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Thin.woff2') format('woff2'),
|
||||
url('Marianne-Thin.woff') format('woff');
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Thin_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Thin_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Thin_Italic.woff') format('woff');
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Light.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Light.woff2') format('woff2'),
|
||||
url('Marianne-Light.woff') format('woff');
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Light_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Light_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Light_Italic.woff') format('woff');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Regular.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Regular.woff2') format('woff2'),
|
||||
url('Marianne-Regular.woff') format('woff');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Regular_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Regular_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Regular_Italic.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Medium.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Medium.woff2') format('woff2'),
|
||||
url('Marianne-Medium.woff') format('woff');
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Medium_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Medium_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Medium_Italic.woff') format('woff');
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Bold.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Bold.woff2') format('woff2'),
|
||||
url('Marianne-Bold.woff') format('woff');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Bold_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-Bold_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Bold_Italic.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-ExtraBold.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-ExtraBold.woff2') format('woff2'),
|
||||
url('Marianne-ExtraBold.woff') format('woff');
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-ExtraBold_Italic.woff') format('truetype');
|
||||
src:
|
||||
url('Marianne-ExtraBold_Italic.woff2') format('woff2'),
|
||||
url('Marianne-ExtraBold_Italic.woff') format('woff');
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface BoxProps {
|
||||
$effect?: 'show' | 'hide';
|
||||
$flex?: boolean;
|
||||
$gap?: CSSProperties['gap'];
|
||||
$hasTransition?: boolean;
|
||||
$hasTransition?: boolean | 'slow';
|
||||
$height?: CSSProperties['height'];
|
||||
$justify?: CSSProperties['justifyContent'];
|
||||
$overflow?: CSSProperties['overflow'];
|
||||
@@ -33,6 +33,7 @@ export interface BoxProps {
|
||||
$padding?: MarginPadding;
|
||||
$position?: CSSProperties['position'];
|
||||
$radius?: CSSProperties['borderRadius'];
|
||||
$shrink?: CSSProperties['flexShrink'];
|
||||
$transition?: CSSProperties['transition'];
|
||||
$width?: CSSProperties['width'];
|
||||
$wrap?: CSSProperties['flexWrap'];
|
||||
@@ -53,7 +54,11 @@ export const Box = styled('div')<BoxProps>`
|
||||
${({ $gap }) => $gap && `gap: ${$gap};`}
|
||||
${({ $height }) => $height && `height: ${$height};`}
|
||||
${({ $hasTransition }) =>
|
||||
$hasTransition && `transition: all 0.3s ease-in-out;`}
|
||||
$hasTransition && $hasTransition === 'slow'
|
||||
? `transition: all 0.5s ease-in-out;`
|
||||
: $hasTransition
|
||||
? `transition: all 0.3s ease-in-out;`
|
||||
: ''}
|
||||
${({ $justify }) => $justify && `justify-content: ${$justify};`}
|
||||
${({ $margin }) => $margin && stylesMargin($margin)}
|
||||
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight};`}
|
||||
@@ -64,6 +69,7 @@ export const Box = styled('div')<BoxProps>`
|
||||
${({ $padding }) => $padding && stylesPadding($padding)}
|
||||
${({ $position }) => $position && `position: ${$position};`}
|
||||
${({ $radius }) => $radius && `border-radius: ${$radius};`}
|
||||
${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`}
|
||||
${({ $transition }) => $transition && `transition: ${$transition};`}
|
||||
${({ $width }) => $width && `width: ${$width};`}
|
||||
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}
|
||||
|
||||
@@ -24,6 +24,8 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
|
||||
ref={ref}
|
||||
as="button"
|
||||
$background="none"
|
||||
$margin="none"
|
||||
$padding="none"
|
||||
$css={`
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
|
||||
@@ -13,8 +13,12 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
|
||||
$isMaterialIcon
|
||||
$size="36px"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$background={colorsTokens()['primary-bg']}
|
||||
$css={`border: 1px solid ${colorsTokens()['primary-200']}`}
|
||||
$css={`
|
||||
border: 1px solid ${colorsTokens()['primary-200']};
|
||||
user-select: none;
|
||||
`}
|
||||
$radius="12px"
|
||||
$padding="4px"
|
||||
$margin="auto"
|
||||
@@ -38,6 +42,7 @@ export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
|
||||
$css={`
|
||||
transition: all 0.3s ease-in-out;
|
||||
transform: rotate(${isOpen ? '90' : '0'}deg);
|
||||
user-select: none;
|
||||
`}
|
||||
>
|
||||
more_vert
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import React, { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Card, IconBG, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
interface PanelProps {
|
||||
title: string;
|
||||
setIsPanelOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const Panel = ({
|
||||
children,
|
||||
title,
|
||||
setIsPanelOpen,
|
||||
}: PropsWithChildren<PanelProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closedOverridingStyles = !isOpen && {
|
||||
$width: '0',
|
||||
$maxWidth: '0',
|
||||
$minWidth: '0',
|
||||
};
|
||||
|
||||
const transition = 'all 0.5s ease-in-out';
|
||||
|
||||
return (
|
||||
<Card
|
||||
$width="100%"
|
||||
$maxWidth="20rem"
|
||||
$position="sticky"
|
||||
$maxHeight="96vh"
|
||||
$height="100%"
|
||||
$css={`
|
||||
top: 2vh;
|
||||
transition: ${transition};
|
||||
${
|
||||
!isOpen &&
|
||||
`
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
`
|
||||
}
|
||||
`}
|
||||
aria-label={t('Document panel')}
|
||||
{...closedOverridingStyles}
|
||||
>
|
||||
<Box
|
||||
$overflow="hidden"
|
||||
$css={`
|
||||
opacity: ${isOpen ? '1' : '0'};
|
||||
transition: ${transition};
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$padding={{ all: 'small' }}
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$css={`border-top: 2px solid ${colorsTokens()['primary-600']};`}
|
||||
>
|
||||
<IconBG
|
||||
iconName="menu_open"
|
||||
aria-label={isOpen ? t('Close the panel') : t('Open the panel')}
|
||||
$background="transparent"
|
||||
$size="h2"
|
||||
$zIndex={1}
|
||||
$css={`
|
||||
cursor: pointer;
|
||||
left: 0rem;
|
||||
top: 0.1rem;
|
||||
transition: ${transition};
|
||||
transform: rotate(180deg);
|
||||
opacity: ${isOpen ? '1' : '0'};
|
||||
user-select: none;
|
||||
`}
|
||||
$position="absolute"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
setTimeout(() => {
|
||||
setIsPanelOpen(false);
|
||||
}, 400);
|
||||
}}
|
||||
$radius="2px"
|
||||
/>
|
||||
<Text $weight="bold" $size="l" $theme="primary">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,34 @@
|
||||
import { Alert, VariantType } from '@openfun/cunningham-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box, Text, TextType } from '@/components';
|
||||
|
||||
const AlertStyled = styled(Alert)`
|
||||
& .c__button--tertiary:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-200);
|
||||
}
|
||||
`;
|
||||
|
||||
interface TextErrorsProps extends TextType {
|
||||
causes?: string[];
|
||||
defaultMessage?: string;
|
||||
icon?: ReactNode;
|
||||
canClose?: boolean;
|
||||
}
|
||||
|
||||
export const TextErrors = ({
|
||||
causes,
|
||||
defaultMessage,
|
||||
icon,
|
||||
canClose = false,
|
||||
...textProps
|
||||
}: TextErrorsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Alert canClose={false} type={VariantType.ERROR} icon={icon}>
|
||||
<AlertStyled canClose={canClose} type={VariantType.ERROR} icon={icon}>
|
||||
<Box $direction="column" $gap="0.2rem">
|
||||
{causes &&
|
||||
causes.map((cause, i) => (
|
||||
@@ -39,6 +48,6 @@ export const TextErrors = ({
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Alert>
|
||||
</AlertStyled>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { CunninghamProvider } from '@openfun/cunningham-react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { E2ESDKClientProvider } from '@socialgouv/e2esdk-react';
|
||||
import { e2esdkClient } from './auth/useAuthStore';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import '@/i18n/initI18n';
|
||||
import { useResponsiveStore } from '@/stores/';
|
||||
|
||||
import { Auth } from './auth/';
|
||||
|
||||
@@ -19,6 +19,7 @@ const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 3,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -26,12 +27,19 @@ const queryClient = new QueryClient({
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const { theme } = useCunninghamTheme();
|
||||
|
||||
const initializeResizeListener = useResponsiveStore(
|
||||
(state) => state.initializeResizeListener,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanupResizeListener = initializeResizeListener();
|
||||
return cleanupResizeListener;
|
||||
}, [initializeResizeListener]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CunninghamProvider theme={theme}>
|
||||
<E2ESDKClientProvider client={e2esdkClient}>
|
||||
<Auth>{children}</Auth>
|
||||
</E2ESDKClientProvider>
|
||||
<Auth>{children}</Auth>
|
||||
</CunninghamProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -17,8 +17,9 @@ import { useAuthStore } from './useAuthStore';
|
||||
const regexpUrlsAuth = [/\/docs\/$/g, /^\/$/g];
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { initAuth, initiated, authenticated, login } = useAuthStore();
|
||||
const { asPath } = useRouter();
|
||||
const { initAuth, initiated, authenticated, login, getAuthUrl } =
|
||||
useAuthStore();
|
||||
const { asPath, replace } = useRouter();
|
||||
|
||||
const [pathAllowed, setPathAllowed] = useState<boolean>(
|
||||
!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)),
|
||||
@@ -41,6 +42,18 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
login();
|
||||
}, [authenticated, pathAllowed, login, initiated]);
|
||||
|
||||
// Redirect to the path before login
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authUrl = getAuthUrl();
|
||||
if (authUrl) {
|
||||
void replace(authUrl);
|
||||
}
|
||||
}, [authenticated, getAuthUrl, replace]);
|
||||
|
||||
if ((!initiated && pathAllowed) || (!authenticated && !pathAllowed)) {
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
|
||||
export const AccountDropdown = () => {
|
||||
export const ButtonLogin = () => {
|
||||
const { t } = useTranslation();
|
||||
const { logout, authenticated, login } = useAuthStore();
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
* Represents user retrieved from the API.
|
||||
* @interface User
|
||||
* @property {string} id - The id of the user.
|
||||
* @property {string} sub - The `sub` field of OIDC
|
||||
* @property {string} email - The email of the user.
|
||||
* @property {string} name - The name of the user.
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
sub: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './AccountDropdown';
|
||||
export * from './api/types';
|
||||
export * from './Auth';
|
||||
export * from './ButtonLogin';
|
||||
export * from './useAuthStore';
|
||||
|
||||
@@ -5,96 +5,58 @@ import { baseApiUrl } from '@/core/conf';
|
||||
import { User, getMe } from './api';
|
||||
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
|
||||
|
||||
import { Client, PublicUserIdentity } from '@socialgouv/e2esdk-client';
|
||||
import { identity } from 'lodash';
|
||||
|
||||
export const e2esdkClient = new Client({
|
||||
// Point it to where your server is listening
|
||||
serverURL: 'https://app-a5a1b445-32e0-4cf4-a478-821a48f86ccf.cleverapps.io',
|
||||
// Pass the signature public key you configured for the server
|
||||
serverSignaturePublicKey: 'ayfva9SUh0mfgmifUtxcdLp4HriHJiqefEKnvYgY4qM',
|
||||
});
|
||||
|
||||
interface AuthStore {
|
||||
initiated: boolean;
|
||||
authenticated: boolean;
|
||||
readyForEncryption: boolean;
|
||||
initAuth: () => void;
|
||||
logout: () => void;
|
||||
login: () => void;
|
||||
endToEndData?: PublicUserIdentity;
|
||||
setAuthUrl: (url: string) => void;
|
||||
getAuthUrl: () => string | undefined;
|
||||
userData?: User;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
initiated: false,
|
||||
authenticated: false,
|
||||
readyForEncryption: false,
|
||||
userData: undefined,
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthStore>((set) => ({
|
||||
export const useAuthStore = create<AuthStore>((set, get) => ({
|
||||
initiated: initialState.initiated,
|
||||
authenticated: initialState.authenticated,
|
||||
userData: initialState.userData,
|
||||
readyForEncryption: initialState.readyForEncryption,
|
||||
|
||||
initAuth: () => {
|
||||
getMe()
|
||||
.then(
|
||||
(data: User) => {
|
||||
// If a path is stored in the local storage, we redirect to it
|
||||
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
if (path_auth) {
|
||||
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
window.location.replace(path_auth);
|
||||
return;
|
||||
}
|
||||
|
||||
set({ authenticated: true, userData: data });
|
||||
return e2esdkClient
|
||||
.signup(data.sub)
|
||||
.then(() => data)
|
||||
.catch(() => data);
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
.then(
|
||||
(data) => {
|
||||
set({ readyForEncryption: true });
|
||||
if (data) {
|
||||
return e2esdkClient.login(data.sub);
|
||||
}
|
||||
},
|
||||
(e) => {
|
||||
throw e;
|
||||
//if (data) {
|
||||
// return e2esdkClient.login(data.sub);
|
||||
//}
|
||||
//fail
|
||||
},
|
||||
)
|
||||
.then((publicIdentity: PublicUserIdentity | null | undefined) => {
|
||||
if (!publicIdentity) throw Error('exploding');
|
||||
console.log('publicIdentity', publicIdentity);
|
||||
set({ endToEndData: publicIdentity });
|
||||
.then((data: User) => {
|
||||
set({ authenticated: true, userData: data });
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
console.log('finally');
|
||||
set({ initiated: true });
|
||||
});
|
||||
},
|
||||
login: () => {
|
||||
// If we try to access a specific page and we are not authenticated
|
||||
// we store the path in the local storage to redirect to it after login
|
||||
if (window.location.pathname !== '/') {
|
||||
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
|
||||
}
|
||||
get().setAuthUrl(window.location.pathname);
|
||||
|
||||
window.location.replace(`${baseApiUrl()}authenticate/`);
|
||||
},
|
||||
logout: () => {
|
||||
window.location.replace(`${baseApiUrl()}logout/`);
|
||||
},
|
||||
// If we try to access a specific page and we are not authenticated
|
||||
// we store the path in the local storage to redirect to it after login
|
||||
setAuthUrl() {
|
||||
if (window.location.pathname !== '/') {
|
||||
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
|
||||
}
|
||||
},
|
||||
// If a path is stored in the local storage, we return it then remove it
|
||||
getAuthUrl() {
|
||||
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
if (path_auth) {
|
||||
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
return path_auth;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -150,6 +150,12 @@ input:-webkit-autofill:focus {
|
||||
border-color: var(--c--components--forms-select--border-color-disabled-hover);
|
||||
}
|
||||
|
||||
.c__select--disabled .c__select__wrapper label,
|
||||
.c__select--disabled .c__select__wrapper input,
|
||||
.c__select--disabled .c__select__wrapper {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.c__select__menu__item {
|
||||
transition: all var(--c--theme--transitions--duration)
|
||||
var(--c--theme--transitions--ease-out);
|
||||
@@ -249,6 +255,7 @@ input:-webkit-autofill:focus {
|
||||
gap: 3px;
|
||||
border-radius: 4px;
|
||||
background: var(--c--components--datagrid--pagination--background-color);
|
||||
border-color: var(--c--components--datagrid--pagination--border-color);
|
||||
}
|
||||
|
||||
.c__pagination__list .c__button--tertiary-text.c__button--active {
|
||||
@@ -313,6 +320,14 @@ input:-webkit-autofill:focus {
|
||||
font-size: var(--c--components--forms-checkbox--text--size);
|
||||
}
|
||||
|
||||
.c__checkbox.c__checkbox--disabled .c__field__text {
|
||||
color: var(--c--theme--colors--greyscale-600);
|
||||
}
|
||||
|
||||
.c__switch.c__checkbox--disabled .c__switch__rail {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button
|
||||
*/
|
||||
@@ -438,6 +453,7 @@ input:-webkit-autofill:focus {
|
||||
|
||||
.c__button--tertiary-text {
|
||||
border: none;
|
||||
color: var(--c--components--button--tertiary-text--color);
|
||||
}
|
||||
|
||||
.c__button--tertiary-text:hover,
|
||||
@@ -493,6 +509,23 @@ input:-webkit-autofill:focus {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media screen and (width <= 420px) {
|
||||
.c__modal__scroller {
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.c__modal__title h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 576px) {
|
||||
.c__modal__footer--sided {
|
||||
gap: 0.5rem;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast
|
||||
*/
|
||||
|
||||
@@ -283,6 +283,7 @@
|
||||
);
|
||||
--c--components--button--disabled--color: white;
|
||||
--c--components--button--disabled--background--color: #b3cef0;
|
||||
--c--components--la-gauffre--activated: false;
|
||||
}
|
||||
|
||||
.cunningham-theme--dark {
|
||||
@@ -451,6 +452,9 @@
|
||||
--c--components--button--tertiary-text--color-hover: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--button--tertiary-text--color: var(
|
||||
--c--theme--colors--primary-600
|
||||
);
|
||||
--c--components--datagrid--header--color: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
@@ -461,6 +465,9 @@
|
||||
--c--components--datagrid--pagination--background-color-active: var(
|
||||
--c--theme--colors--primary-300
|
||||
);
|
||||
--c--components--datagrid--pagination--border-color: var(
|
||||
--c--theme--colors--primary-400
|
||||
);
|
||||
--c--components--forms-checkbox--border-radius: 0;
|
||||
--c--components--forms-checkbox--color: var(--c--theme--colors--primary-text);
|
||||
--c--components--forms-checkbox--text--color: var(
|
||||
@@ -504,6 +511,7 @@
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--forms-textarea--border-radius: 0;
|
||||
--c--components--la-gauffre--activated: true;
|
||||
}
|
||||
|
||||
.clr-secondary-text {
|
||||
|
||||
@@ -276,6 +276,7 @@ export const tokens = {
|
||||
},
|
||||
disabled: { color: 'white', background: { color: '#b3cef0' } },
|
||||
},
|
||||
'la-gauffre': { activated: false },
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
@@ -450,6 +451,7 @@ export const tokens = {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
@@ -464,6 +466,7 @@ export const tokens = {
|
||||
pagination: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-active': 'var(--c--theme--colors--primary-300)',
|
||||
'border-color': 'var(--c--theme--colors--primary-400)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
@@ -503,6 +506,7 @@ export const tokens = {
|
||||
'accent-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-textarea': { 'border-radius': '0' },
|
||||
'la-gauffre': { activated: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Block, BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
|
||||
import { BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
|
||||
import '@blocknote/core/fonts/inter.css';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
import '@blocknote/mantine/style.css';
|
||||
@@ -13,16 +13,19 @@ import { Version } from '@/features/docs/doc-versioning/';
|
||||
|
||||
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
|
||||
import useSaveDoc from '../hook/useSaveDoc';
|
||||
import { useDocStore } from '../stores';
|
||||
import { useDocStore, useHeadingStore } from '../stores';
|
||||
import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteToolbar } from './BlockNoteToolbar';
|
||||
import { useE2ESDKClient } from '@socialgouv/e2esdk-react';
|
||||
|
||||
const cssEditor = `
|
||||
const cssEditor = (readonly: boolean) => `
|
||||
&, & > .bn-container, & .ProseMirror {
|
||||
height:100%
|
||||
};
|
||||
& .bn-editor {
|
||||
padding-right: 30px;
|
||||
${readonly && `padding-left: 30px;`}
|
||||
};
|
||||
& .collaboration-cursor__caret.ProseMirror-widget{
|
||||
word-wrap: initial;
|
||||
}
|
||||
@@ -31,6 +34,35 @@ const cssEditor = `
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@media screen and (width <= 560px) {
|
||||
& .bn-editor {
|
||||
padding-left: 40px;
|
||||
padding-right: 10px;
|
||||
${readonly && `padding-left: 10px;`}
|
||||
};
|
||||
.bn-side-menu[data-block-type=heading][data-level="1"] {
|
||||
height: 46px;
|
||||
}
|
||||
.bn-side-menu[data-block-type=heading][data-level="2"] {
|
||||
height: 40px;
|
||||
}
|
||||
.bn-side-menu[data-block-type=heading][data-level="3"] {
|
||||
height: 40px;
|
||||
}
|
||||
& .bn-editor h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
& .bn-editor h2 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
& .bn-editor h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type="paragraph"]
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
@@ -71,15 +103,16 @@ export const BlockNoteContent = ({
|
||||
const isVersion = doc.id !== storeId;
|
||||
const { userData } = useAuthStore();
|
||||
const { setStore, docsStore } = useDocStore();
|
||||
const canSave = doc.abilities.partial_update && !isVersion;
|
||||
|
||||
const e2eClient = useE2ESDKClient();
|
||||
const readOnly = !doc.abilities.partial_update || isVersion;
|
||||
useSaveDoc(doc.id, provider.document, !readOnly);
|
||||
const storedEditor = docsStore?.[storeId]?.editor;
|
||||
const {
|
||||
mutateAsync: createDocAttachment,
|
||||
isError: isErrorAttachment,
|
||||
error: errorAttachment,
|
||||
} = useCreateDocAttachment();
|
||||
const { setHeadings, resetHeadings } = useHeadingStore();
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
@@ -101,56 +134,51 @@ export const BlockNoteContent = ({
|
||||
return storedEditor;
|
||||
}
|
||||
|
||||
// TODO decrypt doc.content
|
||||
//localStorage.getItem('KEY');
|
||||
|
||||
const docId = 'uuid-du-doc';
|
||||
const purpose = `doc:${docId}`;
|
||||
const key = e2eClient.findKeyByPurpose(purpose);
|
||||
if (!key) {
|
||||
alert('probleme de key');
|
||||
// return;
|
||||
} else {
|
||||
const decryptedMessage = e2eClient.decrypt(
|
||||
doc.content,
|
||||
key.keychainFingerprint,
|
||||
);
|
||||
|
||||
console.log('decryptedMessage', decryptedMessage);
|
||||
}
|
||||
|
||||
return BlockNoteEditorCore.create({
|
||||
// collaboration: {
|
||||
// provider,
|
||||
// fragment: provider.document.getXmlFragment('document-store'),
|
||||
// user: {
|
||||
// name: userData?.email || 'Anonymous',
|
||||
// color: randomColor(),
|
||||
// },
|
||||
// },
|
||||
collaboration: {
|
||||
provider,
|
||||
fragment: provider.document.getXmlFragment('document-store'),
|
||||
user: {
|
||||
name: userData?.email || 'Anonymous',
|
||||
color: randomColor(),
|
||||
},
|
||||
},
|
||||
uploadFile,
|
||||
initialContent: JSON.parse(doc.content),
|
||||
});
|
||||
}, [doc.content, storedEditor, uploadFile]);
|
||||
|
||||
useSaveDoc(doc.id, provider.document, canSave, editor);
|
||||
}, [provider, storedEditor, uploadFile, userData?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
setStore(storeId, { editor });
|
||||
}, [setStore, storeId, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
setHeadings(editor);
|
||||
|
||||
editor?.onEditorContentChange(() => {
|
||||
setHeadings(editor);
|
||||
});
|
||||
|
||||
return () => {
|
||||
resetHeadings();
|
||||
};
|
||||
}, [editor, resetHeadings, setHeadings]);
|
||||
|
||||
return (
|
||||
<Box $css={cssEditor}>
|
||||
<Box $css={cssEditor(readOnly)}>
|
||||
{isErrorAttachment && (
|
||||
<Box $margin={{ bottom: 'big' }}>
|
||||
<TextErrors causes={errorAttachment.cause} />
|
||||
<TextErrors
|
||||
causes={errorAttachment.cause}
|
||||
canClose
|
||||
$textAlign="left"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
formattingToolbar={false}
|
||||
editable={doc.abilities.partial_update && !isVersion}
|
||||
editable={!readOnly}
|
||||
theme="light"
|
||||
>
|
||||
<BlockNoteToolbar />
|
||||
|
||||
@@ -9,12 +9,10 @@ import {
|
||||
NestBlockButton,
|
||||
TextAlignButton,
|
||||
UnnestBlockButton,
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useSelectedBlocks,
|
||||
} from '@blocknote/react';
|
||||
import { forEach, isArray } from 'lodash';
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { MarkdownButton } from './MarkdownButton';
|
||||
|
||||
export const BlockNoteToolbar = () => {
|
||||
return (
|
||||
@@ -57,79 +55,3 @@ export const BlockNoteToolbar = () => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type Block = {
|
||||
type: string;
|
||||
text: string;
|
||||
content: Block[];
|
||||
};
|
||||
|
||||
function isBlock(block: Block): block is Block {
|
||||
return (
|
||||
block.content &&
|
||||
isArray(block.content) &&
|
||||
block.content.length > 0 &&
|
||||
typeof block.type !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
const recursiveContent = (content: Block[], base: string = '') => {
|
||||
let fullContent = base;
|
||||
for (const innerContent of content) {
|
||||
if (innerContent.type === 'text') {
|
||||
fullContent += innerContent.text;
|
||||
} else if (isBlock(innerContent)) {
|
||||
fullContent = recursiveContent(innerContent.content, fullContent);
|
||||
}
|
||||
}
|
||||
|
||||
return fullContent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom Formatting Toolbar Button to convert markdown to json.
|
||||
*/
|
||||
export function MarkdownButton() {
|
||||
const editor = useBlockNoteEditor();
|
||||
const Components = useComponentsContext();
|
||||
const selectedBlocks = useSelectedBlocks(editor);
|
||||
|
||||
const handleConvertMarkdown = () => {
|
||||
const blocks = editor.getSelection()?.blocks;
|
||||
|
||||
forEach(blocks, async (block) => {
|
||||
if (!isBlock(block as unknown as Block)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullContent = recursiveContent(
|
||||
block.content as unknown as Block[],
|
||||
);
|
||||
|
||||
const blockMarkdown =
|
||||
await editor.tryParseMarkdownToBlocks(fullContent);
|
||||
editor.replaceBlocks([block.id], blockMarkdown);
|
||||
} catch (error) {
|
||||
console.error('Error parsing Markdown:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const show = useMemo(() => {
|
||||
return !!selectedBlocks.find((block) => block.content !== undefined);
|
||||
}, [selectedBlocks]);
|
||||
|
||||
if (!show || !editor.isEditable || !Components) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Components.FormattingToolbar.Button
|
||||
mainTooltip="Convert Markdown"
|
||||
onClick={handleConvertMarkdown}
|
||||
>
|
||||
M
|
||||
</Components.FormattingToolbar.Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,19 +5,16 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Card, Text, TextErrors } from '@/components';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { DocHeader } from '@/features/docs/doc-header';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
import { Summary, useDocSummaryStore } from '@/features/docs/doc-summary';
|
||||
import {
|
||||
VersionList,
|
||||
Versions,
|
||||
useDocVersion,
|
||||
useDocVersionStore,
|
||||
} from '@/features/docs/doc-versioning/';
|
||||
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useHeadingStore } from '../stores';
|
||||
|
||||
import { BlockNoteEditor } from './BlockNoteEditor';
|
||||
import { IconOpenPanelEditor, PanelEditor } from './PanelEditor';
|
||||
|
||||
interface DocEditorProps {
|
||||
doc: Doc;
|
||||
@@ -27,10 +24,9 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
const {
|
||||
query: { versionId },
|
||||
} = useRouter();
|
||||
const { isPanelVersionOpen, setIsPanelVersionOpen } = useDocVersionStore();
|
||||
const { isPanelSummaryOpen, setIsPanelSummaryOpen } = useDocSummaryStore();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { headings } = useHeadingStore();
|
||||
const { isMobile } = useResponsiveStore();
|
||||
|
||||
const isVersion = versionId && typeof versionId === 'string';
|
||||
|
||||
@@ -57,26 +53,24 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
$background={colorsTokens()['primary-bg']}
|
||||
$height="100%"
|
||||
$direction="row"
|
||||
$margin={{ all: 'small', top: 'none' }}
|
||||
$gap="1rem"
|
||||
$margin={{ all: isMobile ? 'tiny' : 'small', top: 'none' }}
|
||||
$css="overflow-x: clip;"
|
||||
$position="relative"
|
||||
>
|
||||
<Card $padding="big" $css="flex:1;" $overflow="auto">
|
||||
<Card
|
||||
$padding={isMobile ? 'small' : 'big'}
|
||||
$css="flex:1;"
|
||||
$overflow="auto"
|
||||
$position="relative"
|
||||
>
|
||||
{isVersion ? (
|
||||
<DocVersionEditor doc={doc} versionId={versionId} />
|
||||
) : (
|
||||
<BlockNoteEditor doc={doc} />
|
||||
)}
|
||||
{!isMobile && <IconOpenPanelEditor headings={headings} />}
|
||||
</Card>
|
||||
{doc.abilities.versions_list && isPanelVersionOpen && (
|
||||
<Panel title={t('VERSIONS')} setIsPanelOpen={setIsPanelVersionOpen}>
|
||||
<VersionList doc={doc} />
|
||||
</Panel>
|
||||
)}
|
||||
{isPanelSummaryOpen && (
|
||||
<Panel title={t('SUMMARY')} setIsPanelOpen={setIsPanelSummaryOpen}>
|
||||
<Summary doc={doc} />
|
||||
</Panel>
|
||||
)}
|
||||
<PanelEditor doc={doc} headings={headings} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import '@blocknote/mantine/style.css';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useSelectedBlocks,
|
||||
} from '@blocknote/react';
|
||||
import { forEach, isArray } from 'lodash';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
type Block = {
|
||||
type: string;
|
||||
text: string;
|
||||
content: Block[];
|
||||
};
|
||||
|
||||
function isBlock(block: Block): block is Block {
|
||||
return (
|
||||
block.content &&
|
||||
isArray(block.content) &&
|
||||
block.content.length > 0 &&
|
||||
typeof block.type !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
const recursiveContent = (content: Block[], base: string = '') => {
|
||||
let fullContent = base;
|
||||
for (const innerContent of content) {
|
||||
if (innerContent.type === 'text') {
|
||||
fullContent += innerContent.text;
|
||||
} else if (isBlock(innerContent)) {
|
||||
fullContent = recursiveContent(innerContent.content, fullContent);
|
||||
}
|
||||
}
|
||||
|
||||
return fullContent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom Formatting Toolbar Button to convert markdown to json.
|
||||
*/
|
||||
export function MarkdownButton() {
|
||||
const editor = useBlockNoteEditor();
|
||||
const Components = useComponentsContext();
|
||||
const selectedBlocks = useSelectedBlocks(editor);
|
||||
|
||||
const handleConvertMarkdown = () => {
|
||||
const blocks = editor.getSelection()?.blocks;
|
||||
|
||||
forEach(blocks, async (block) => {
|
||||
if (!isBlock(block as unknown as Block)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullContent = recursiveContent(
|
||||
block.content as unknown as Block[],
|
||||
);
|
||||
|
||||
const blockMarkdown =
|
||||
await editor.tryParseMarkdownToBlocks(fullContent);
|
||||
editor.replaceBlocks([block.id], blockMarkdown);
|
||||
} catch (error) {
|
||||
console.error('Error parsing Markdown:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const show = useMemo(() => {
|
||||
return !!selectedBlocks.find((block) => block.content !== undefined);
|
||||
}, [selectedBlocks]);
|
||||
|
||||
if (!show || !editor.isEditable || !Components) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Components.FormattingToolbar.Button
|
||||
mainTooltip="Convert Markdown"
|
||||
onClick={handleConvertMarkdown}
|
||||
>
|
||||
M
|
||||
</Components.FormattingToolbar.Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, BoxButton, Card, IconBG, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
import { TableContent } from '@/features/docs/doc-table-content';
|
||||
import { VersionList } from '@/features/docs/doc-versioning';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { usePanelEditorStore } from '../stores';
|
||||
import { HeadingBlock } from '../types';
|
||||
|
||||
interface PanelProps {
|
||||
doc: Doc;
|
||||
headings: HeadingBlock[];
|
||||
}
|
||||
|
||||
export const PanelEditor = ({
|
||||
doc,
|
||||
headings,
|
||||
}: PropsWithChildren<PanelProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { isMobile } = useResponsiveStore();
|
||||
const { isPanelTableContentOpen, setIsPanelTableContentOpen, isPanelOpen } =
|
||||
usePanelEditorStore();
|
||||
|
||||
return (
|
||||
<Card
|
||||
$width="100%"
|
||||
$maxWidth="20rem"
|
||||
$position={isMobile ? 'absolute' : 'sticky'}
|
||||
$height="100%"
|
||||
$hasTransition="slow"
|
||||
$css={`
|
||||
top: 0vh;
|
||||
right: 0;
|
||||
transform: translateX(0%);
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
${
|
||||
!isPanelOpen &&
|
||||
`
|
||||
transform: translateX(200%);
|
||||
opacity: 0;
|
||||
flex: 0;
|
||||
margin-left: 0rem;
|
||||
max-width: 0rem;
|
||||
`
|
||||
}
|
||||
`}
|
||||
aria-label={t('Document panel')}
|
||||
aria-hidden={!isPanelOpen}
|
||||
>
|
||||
<Box
|
||||
$overflow="inherit"
|
||||
$position="sticky"
|
||||
$hasTransition="slow"
|
||||
$css={`
|
||||
top: 0;
|
||||
opacity: ${isPanelOpen ? '1' : '0'};
|
||||
`}
|
||||
$maxHeight="99vh"
|
||||
>
|
||||
{isMobile && <IconOpenPanelEditor headings={headings} />}
|
||||
<Box
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$align="center"
|
||||
$position="relative"
|
||||
$background={colorsTokens()['primary-400']}
|
||||
$margin={{ bottom: 'tiny' }}
|
||||
$radius="4px 4px 0 0"
|
||||
>
|
||||
<Box
|
||||
$background="white"
|
||||
$position="absolute"
|
||||
$height="100%"
|
||||
$width={doc.abilities.versions_list ? '50%' : '100%'}
|
||||
$hasTransition="slow"
|
||||
$css={`
|
||||
border-top: 2px solid ${colorsTokens()['primary-600']};
|
||||
border-radius: 0 4px 0 0;
|
||||
${
|
||||
isPanelTableContentOpen
|
||||
? `
|
||||
transform: translateX(0);
|
||||
border-radius: 4px 0 0 0;
|
||||
`
|
||||
: `transform: translateX(100%);`
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<BoxButton
|
||||
$minWidth={doc.abilities.versions_list ? '50%' : '100%'}
|
||||
onClick={() => setIsPanelTableContentOpen(true)}
|
||||
$zIndex={1}
|
||||
>
|
||||
<Text
|
||||
$width="100%"
|
||||
$weight="bold"
|
||||
$size="m"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$padding={{ vertical: 'small', horizontal: 'small' }}
|
||||
>
|
||||
{t('Table of content')}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
{doc.abilities.versions_list && (
|
||||
<BoxButton
|
||||
$minWidth="50%"
|
||||
onClick={() => setIsPanelTableContentOpen(false)}
|
||||
$zIndex={1}
|
||||
>
|
||||
<Text
|
||||
$width="100%"
|
||||
$weight="bold"
|
||||
$size="m"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$padding={{ vertical: 'small', horizontal: 'small' }}
|
||||
>
|
||||
{t('Versions')}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
)}
|
||||
</Box>
|
||||
{isPanelTableContentOpen && (
|
||||
<TableContent doc={doc} headings={headings} />
|
||||
)}
|
||||
{!isPanelTableContentOpen && doc.abilities.versions_list && (
|
||||
<VersionList doc={doc} />
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface IconOpenPanelEditorProps {
|
||||
headings: HeadingBlock[];
|
||||
}
|
||||
|
||||
export const IconOpenPanelEditor = ({ headings }: IconOpenPanelEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setIsPanelOpen, isPanelOpen, setIsPanelTableContentOpen } =
|
||||
usePanelEditorStore();
|
||||
const [hasBeenOpen, setHasBeenOpen] = useState(isPanelOpen);
|
||||
const { isMobile } = useResponsiveStore();
|
||||
|
||||
const setClosePanel = () => {
|
||||
setHasBeenOpen(true);
|
||||
setIsPanelOpen(!isPanelOpen);
|
||||
};
|
||||
|
||||
// Open the panel if there are more than 1 heading
|
||||
useEffect(() => {
|
||||
if (headings?.length && headings.length > 1 && !hasBeenOpen && !isMobile) {
|
||||
setIsPanelTableContentOpen(true);
|
||||
setIsPanelOpen(true);
|
||||
setHasBeenOpen(true);
|
||||
}
|
||||
}, [
|
||||
headings,
|
||||
setIsPanelTableContentOpen,
|
||||
setIsPanelOpen,
|
||||
hasBeenOpen,
|
||||
isMobile,
|
||||
]);
|
||||
|
||||
// If open from the doc header we set the state as well
|
||||
useEffect(() => {
|
||||
if (isPanelOpen && !hasBeenOpen) {
|
||||
setHasBeenOpen(true);
|
||||
}
|
||||
}, [hasBeenOpen, isPanelOpen]);
|
||||
|
||||
// Close the panel unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setIsPanelOpen(false);
|
||||
};
|
||||
}, [setIsPanelOpen]);
|
||||
|
||||
return (
|
||||
<IconBG
|
||||
iconName="menu_open"
|
||||
aria-label={isPanelOpen ? t('Close the panel') : t('Open the panel')}
|
||||
$background="transparent"
|
||||
$size="h2"
|
||||
$zIndex={10}
|
||||
$hasTransition="slow"
|
||||
$css={`
|
||||
cursor: pointer;
|
||||
right: 0rem;
|
||||
top: 0.1rem;
|
||||
transform: rotate(${isPanelOpen ? '180deg' : '0deg'});
|
||||
user-select: none;
|
||||
${hasBeenOpen ? 'display:flex;' : 'display: none;'}
|
||||
`}
|
||||
$position="absolute"
|
||||
onClick={setClosePanel}
|
||||
$radius="2px"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,17 @@
|
||||
import { BlockNoteEditor } from '@blocknote/core';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { useUpdateDoc } from '@/features/docs/doc-management/';
|
||||
import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning';
|
||||
import { isFirefox } from '@/utils/userAgent';
|
||||
|
||||
import { toBase64 } from '../utils';
|
||||
import { useE2ESDKClient } from '@socialgouv/e2esdk-react';
|
||||
|
||||
const useSaveDoc = (
|
||||
docId: string,
|
||||
doc: Y.Doc,
|
||||
canSave: boolean,
|
||||
editor: BlockNoteEditor,
|
||||
) => {
|
||||
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
|
||||
});
|
||||
const e2eClient = useE2ESDKClient();
|
||||
const [initialDoc, setInitialDoc] = useState<string>(
|
||||
toBase64(Y.encodeStateAsUpdate(doc)),
|
||||
);
|
||||
@@ -64,32 +57,14 @@ const useSaveDoc = (
|
||||
}, [canSave, hasChanged]);
|
||||
|
||||
const saveDoc = useCallback(() => {
|
||||
const newDoc = JSON.stringify(editor.document);
|
||||
//const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
||||
|
||||
// TODO encode the content
|
||||
|
||||
const docId = 'uuid-du-doc';
|
||||
const purpose = `doc:${docId}`;
|
||||
const key = e2eClient.findKeyByPurpose(purpose);
|
||||
if (!key) {
|
||||
alert('probleme de key');
|
||||
return;
|
||||
}
|
||||
|
||||
const encrypted = e2eClient.encrypt(newDoc, key.keychainFingerprint);
|
||||
|
||||
console.log('encrypted', encrypted);
|
||||
|
||||
// todo
|
||||
|
||||
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
||||
setInitialDoc(newDoc);
|
||||
|
||||
updateDoc({
|
||||
id: docId,
|
||||
content: newDoc,
|
||||
});
|
||||
}, [docId, editor?.document, updateDoc]);
|
||||
}, [doc, docId, updateDoc]);
|
||||
|
||||
const timeout = useRef<NodeJS.Timeout>();
|
||||
const router = useRouter();
|
||||
@@ -113,10 +88,7 @@ const useSaveDoc = (
|
||||
* if he wants to leave the page, by adding the popup, we let the time to the
|
||||
* request to be sent, and intercepted by the service worker (for the offline part).
|
||||
*/
|
||||
const isFirefox =
|
||||
navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
|
||||
|
||||
if (typeof e !== 'undefined' && e.preventDefault && isFirefox) {
|
||||
if (typeof e !== 'undefined' && e.preventDefault && isFirefox()) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './components';
|
||||
export * from './stores';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from './useDocStore';
|
||||
export * from './useHeadingStore';
|
||||
export * from './usePanelEditorStore';
|
||||
|
||||
@@ -6,6 +6,8 @@ import { create } from 'zustand';
|
||||
import { providerUrl } from '@/core';
|
||||
import { Base64 } from '@/features/docs/doc-management';
|
||||
|
||||
import { blocksToYDoc } from '../utils';
|
||||
|
||||
interface DocStore {
|
||||
provider: HocuspocusProvider;
|
||||
editor?: BlockNoteEditor;
|
||||
@@ -26,9 +28,18 @@ export const useDocStore = create<UseDocStore>((set, get) => ({
|
||||
guid: storeId,
|
||||
});
|
||||
|
||||
// if (initialDoc) {
|
||||
// Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
|
||||
// }
|
||||
if (initialDoc) {
|
||||
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
|
||||
} else {
|
||||
const initialDocContent = [
|
||||
{
|
||||
type: 'heading',
|
||||
content: '',
|
||||
},
|
||||
];
|
||||
|
||||
blocksToYDoc(initialDocContent, doc);
|
||||
}
|
||||
|
||||
const provider = new HocuspocusProvider({
|
||||
url: providerUrl(storeId),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BlockNoteEditor } from '@blocknote/core';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { HeadingBlock } from '../types';
|
||||
|
||||
const recursiveTextContent = (content: HeadingBlock['content']): string => {
|
||||
if (!content) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return content.reduce((acc, content) => {
|
||||
if (content.type === 'text') {
|
||||
return acc + content.text;
|
||||
} else if (content.type === 'link') {
|
||||
return acc + recursiveTextContent(content.content);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, '');
|
||||
};
|
||||
|
||||
export interface UseHeadingStore {
|
||||
headings: HeadingBlock[];
|
||||
setHeadings: (editor: BlockNoteEditor) => void;
|
||||
resetHeadings: () => void;
|
||||
}
|
||||
|
||||
export const useHeadingStore = create<UseHeadingStore>((set) => ({
|
||||
headings: [],
|
||||
setHeadings: (editor) => {
|
||||
const headingBlocks = editor?.document
|
||||
.filter((block) => block.type === 'heading')
|
||||
.map((block) => ({
|
||||
...block,
|
||||
contentText: recursiveTextContent(
|
||||
block.content as unknown as HeadingBlock['content'],
|
||||
),
|
||||
})) as unknown as HeadingBlock[];
|
||||
|
||||
set(() => ({ headings: headingBlocks }));
|
||||
},
|
||||
resetHeadings: () => set(() => ({ headings: [] })),
|
||||
}));
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface UsePanelEditorStore {
|
||||
isPanelOpen: boolean;
|
||||
setIsPanelOpen: (isOpen: boolean) => void;
|
||||
isPanelTableContentOpen: boolean;
|
||||
setIsPanelTableContentOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const usePanelEditorStore = create<UsePanelEditorStore>((set) => ({
|
||||
isPanelOpen: false,
|
||||
isPanelTableContentOpen: true,
|
||||
setIsPanelTableContentOpen: (isPanelTableContentOpen) => {
|
||||
set(() => ({ isPanelTableContentOpen }));
|
||||
},
|
||||
setIsPanelOpen: (isPanelOpen) => {
|
||||
set(() => ({ isPanelOpen }));
|
||||
},
|
||||
}));
|
||||
@@ -1,3 +1,14 @@
|
||||
export interface DocAttachment {
|
||||
file: string;
|
||||
}
|
||||
|
||||
export type HeadingBlock = {
|
||||
id: string;
|
||||
type: string;
|
||||
text: string;
|
||||
content: HeadingBlock[];
|
||||
contentText: string;
|
||||
props: {
|
||||
level: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export const randomColor = () => {
|
||||
const randomInt = (min: number, max: number) => {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
@@ -26,3 +28,20 @@ function hslToHex(h: number, s: number, l: number) {
|
||||
export const toBase64 = (
|
||||
str: WithImplicitCoercion<ArrayBuffer | SharedArrayBuffer>,
|
||||
) => Buffer.from(str).toString('base64');
|
||||
|
||||
type BasicBlock = {
|
||||
type: string;
|
||||
content: string;
|
||||
};
|
||||
export const blocksToYDoc = (blocks: BasicBlock[], doc: Y.Doc) => {
|
||||
const xmlFragment = doc.getXmlFragment('document-store');
|
||||
|
||||
blocks.forEach((block) => {
|
||||
const xmlElement = new Y.XmlElement(block.type);
|
||||
if (block.content) {
|
||||
xmlElement.insert(0, [new Y.XmlText(block.content)]);
|
||||
}
|
||||
|
||||
xmlFragment.push([xmlElement]);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import React, { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Card, StyledLink, Text } from '@/components';
|
||||
@@ -8,12 +7,14 @@ import {
|
||||
Doc,
|
||||
Role,
|
||||
currentDocRole,
|
||||
useTransRole,
|
||||
useTrans,
|
||||
} from '@/features/docs/doc-management';
|
||||
import { ModalVersion, Versions } from '@/features/docs/doc-versioning';
|
||||
import { Versions } from '@/features/docs/doc-versioning';
|
||||
import { useDate } from '@/hook';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { DocTagPublic } from './DocTagPublic';
|
||||
import { DocTitle } from './DocTitle';
|
||||
import { DocToolBox } from './DocToolBox';
|
||||
|
||||
interface DocHeaderProps {
|
||||
@@ -25,20 +26,25 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { t } = useTranslation();
|
||||
const { formatDate } = useDate();
|
||||
const transRole = useTransRole();
|
||||
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
|
||||
const { transRole } = useTrans();
|
||||
const { isMobile, isSmallMobile } = useResponsiveStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
$margin="small"
|
||||
$margin={isMobile ? 'tiny' : 'small'}
|
||||
aria-label={t('It is the card information about the document.')}
|
||||
>
|
||||
<Box $padding="small" $direction="row" $align="center">
|
||||
<Box
|
||||
$padding={isMobile ? 'tiny' : 'small'}
|
||||
$direction="row"
|
||||
$align="center"
|
||||
>
|
||||
<StyledLink href="/">
|
||||
<Text
|
||||
$isMaterialIcon
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$size="2rem"
|
||||
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
|
||||
$hasTransition
|
||||
@@ -52,39 +58,39 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
|
||||
$width="1px"
|
||||
$height="70%"
|
||||
$background={colorsTokens()['greyscale-100']}
|
||||
$margin={{ horizontal: 'small' }}
|
||||
$margin={{ horizontal: 'tiny' }}
|
||||
/>
|
||||
<Box $gap="1rem" $direction="row">
|
||||
<Text
|
||||
as="h2"
|
||||
$align="center"
|
||||
$margin={{ all: 'none', left: 'tiny' }}
|
||||
>
|
||||
{doc.title}
|
||||
</Text>
|
||||
{versionId && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsModalVersionOpen(true);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{t('Restore this version')}
|
||||
</Button>
|
||||
)}
|
||||
<Box
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$css="flex:1;"
|
||||
$gap="0.5rem 1rem"
|
||||
$wrap="wrap"
|
||||
$align="center"
|
||||
>
|
||||
<DocTitle doc={doc} />
|
||||
<DocToolBox doc={doc} versionId={versionId} />
|
||||
</Box>
|
||||
<DocToolBox doc={doc} />
|
||||
</Box>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$direction={isSmallMobile ? 'column' : 'row'}
|
||||
$align={isSmallMobile ? 'start' : 'center'}
|
||||
$css="border-top:1px solid #eee"
|
||||
$padding={{ horizontal: 'big', vertical: 'tiny' }}
|
||||
$padding={{
|
||||
horizontal: isMobile ? 'tiny' : 'big',
|
||||
vertical: 'tiny',
|
||||
}}
|
||||
$gap="0.5rem 2rem"
|
||||
$justify="space-between"
|
||||
$wrap="wrap"
|
||||
$position="relative"
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap="0.5rem 2rem" $wrap="wrap">
|
||||
<Box
|
||||
$direction={isSmallMobile ? 'column' : 'row'}
|
||||
$align={isSmallMobile ? 'start' : 'center'}
|
||||
$gap="0.5rem 2rem"
|
||||
$wrap="wrap"
|
||||
>
|
||||
<DocTagPublic doc={doc} />
|
||||
<Text $size="s" $display="inline">
|
||||
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
|
||||
@@ -111,13 +117,6 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
{isModalVersionOpen && versionId && (
|
||||
<ModalVersion
|
||||
onClose={() => setIsModalVersionOpen(false)}
|
||||
docId={doc.id}
|
||||
versionId={versionId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc, LinkReach } from '@/features/docs/doc-management';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
interface DocTagPublicProps {
|
||||
doc: Doc;
|
||||
@@ -11,6 +12,7 @@ interface DocTagPublicProps {
|
||||
export const DocTagPublic = ({ doc }: DocTagPublicProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { t } = useTranslation();
|
||||
const { isSmallMobile } = useResponsiveStore();
|
||||
|
||||
if (doc?.link_reach !== LinkReach.PUBLIC) {
|
||||
return null;
|
||||
@@ -24,6 +26,8 @@ export const DocTagPublic = ({ doc }: DocTagPublicProps) => {
|
||||
$padding="xtiny"
|
||||
$radius="3px"
|
||||
$size="s"
|
||||
$position={isSmallMobile ? 'absolute' : 'initial'}
|
||||
$css={isSmallMobile ? 'right: 10px;' : ''}
|
||||
>
|
||||
{t('Public')}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||
import {
|
||||
Tooltip,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useHeadingStore } from '@/features/docs/doc-editor';
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
useTrans,
|
||||
useUpdateDoc,
|
||||
} from '@/features/docs/doc-management';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
import { isFirefox } from '@/utils/userAgent';
|
||||
|
||||
const DocTitleStyle = createGlobalStyle`
|
||||
.c__tooltip {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface DocTitleProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const DocTitle = ({ doc }: DocTitleProps) => {
|
||||
const { isMobile } = useResponsiveStore();
|
||||
|
||||
if (!doc.abilities.partial_update) {
|
||||
return (
|
||||
<Text
|
||||
as="h2"
|
||||
$margin={{ all: 'none', left: 'tiny' }}
|
||||
$size={isMobile ? 'h4' : 'h2'}
|
||||
>
|
||||
{doc.title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <DocTitleInput doc={doc} />;
|
||||
};
|
||||
|
||||
const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const [titleDisplay, setTitleDisplay] = useState(doc.title);
|
||||
const { toast } = useToastProvider();
|
||||
const { untitledDocument } = useTrans();
|
||||
const isUntitled = titleDisplay === untitledDocument;
|
||||
const { headings } = useHeadingStore();
|
||||
const headingText = headings?.[0]?.contentText;
|
||||
const debounceRef = useRef<NodeJS.Timeout>();
|
||||
const { isMobile } = useResponsiveStore();
|
||||
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
onSuccess(data) {
|
||||
if (data.title !== untitledDocument) {
|
||||
toast(t('Document title updated successfully'), VariantType.SUCCESS);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleTitleSubmit = useCallback(
|
||||
(inputText: string) => {
|
||||
let sanitizedTitle = inputText.trim();
|
||||
sanitizedTitle = sanitizedTitle.replace(/(\r\n|\n|\r)/gm, '');
|
||||
|
||||
// When blank we set to untitled
|
||||
if (!sanitizedTitle) {
|
||||
sanitizedTitle = untitledDocument;
|
||||
setTitleDisplay(sanitizedTitle);
|
||||
}
|
||||
|
||||
// If mutation we update
|
||||
if (sanitizedTitle !== doc.title) {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = undefined;
|
||||
}
|
||||
updateDoc({ id: doc.id, title: sanitizedTitle });
|
||||
}
|
||||
},
|
||||
[doc.id, doc.title, untitledDocument, updateDoc],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleTitleSubmit(e.currentTarget.textContent || '');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnClick = () => {
|
||||
if (isUntitled) {
|
||||
setTitleDisplay('');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ((!debounceRef.current && !isUntitled) || !headingText) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTitleDisplay(headingText);
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
handleTitleSubmit(headingText);
|
||||
debounceRef.current = undefined;
|
||||
}, 3000);
|
||||
}, [isUntitled, handleTitleSubmit, headingText]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocTitleStyle />
|
||||
<Tooltip content={t('Rename')} placement="top">
|
||||
<Box
|
||||
as="h2"
|
||||
$radius="4px"
|
||||
$padding={{ horizontal: 'tiny', vertical: '4px' }}
|
||||
$margin="none"
|
||||
contentEditable={isFirefox() ? 'true' : 'plaintext-only'}
|
||||
onClick={handleOnClick}
|
||||
onBlurCapture={(e) =>
|
||||
handleTitleSubmit(e.currentTarget.textContent || '')
|
||||
}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
$color={
|
||||
isUntitled
|
||||
? colorsTokens()['greyscale-200']
|
||||
: colorsTokens()['greyscale-text']
|
||||
}
|
||||
$css={`
|
||||
${isUntitled && 'font-style: italic;'}
|
||||
cursor: text;
|
||||
font-size: ${isMobile ? '1.2rem' : '1.5rem'};
|
||||
transition: box-shadow 0.5s, border-color 0.5s;
|
||||
border: 1px dashed transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(0, 123, 255, 0.25);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
`}
|
||||
>
|
||||
{titleDisplay}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user