Compare commits

..

32 Commits

Author SHA1 Message Date
Manuel Raynaud
fed3ad6a81 fixup! 📝(doc) add documentation to install using compose 2025-01-28 16:31:49 +01:00
Manuel Raynaud
350643a4c8 📝(doc) add documentation to install using compose
Write the documentation related to the docker compose installation.
2025-01-28 12:35:30 +01:00
Manuel Raynaud
6f62d8ec2a (compose) a config to deploy docs in production using compose
To ease the deployment of the application on infrastructure not running
k8s we created a compose file for production.
2025-01-28 12:35:30 +01:00
Manuel Raynaud
24328b5d6b ♻️(compose) remove usage of dockerize
Compose is able to manage service status to be healthy in its
dependecies. Configuring a healthcheck on services then defining a
condition on the depends_on settings allow to remove dockerize
2025-01-28 10:41:20 +01:00
Manuel Raynaud
9179fdb2fa 🔧(compose) rename legacy docker-compose.yml in compose.yaml
The compose configuration preferred filename is now compose.yaml
docker-compose.yml is the legacy one used by docker-compose v1
2025-01-28 10:41:18 +01:00
Manuel Raynaud
0d7d42254b (helm) add a job allowing to run arbitrary management command
For a specific deployment we may need to run a specific management
command, like the one added previously updating all files content-type.
A template is added responsible to manage this case. The job will be
created only if the backend.job.command is set.
2025-01-28 10:33:30 +01:00
Anthony LC
67dc7feb98 🚑️(backend) command to update attachment content-type
The uploaded files in the system are missing
the content-type.
We add a command to update the content-type of
the existing uploaded files.
This command will run one time when we will deploy
to the environments.
2025-01-28 10:33:30 +01:00
Anthony LC
5b4b100e90 🏷️(backend) add content-type to uploaded files
All the uploaded files had the content-type set
to `application/octet-stream`. It create issues
when the file is downloaded from the frontend
because the browser doesn't know how to handle
the file.
We now determine the content-type of the file
and set it to the file object.
2025-01-28 10:33:30 +01:00
Anthony LC
b8be010389 🚚(helm) add posthog proxy
To contourn ads blocker, we add a proxy to the
posthog service. This way, we can access the
service from the same domain as the frontend.
2025-01-28 10:05:37 +01:00
Anthony LC
97cfa2c1ad (frontend) integrate posthog analytics
We integrate posthog, it will help us to track
user behavior and improve the product.
We get the configuration from the backend config
endpoint.
2025-01-28 10:05:37 +01:00
Anthony LC
c018c6fcf5 🔧(backend) add posthog configuration
We add the posthog configuration to the project.
We will expose the posthog configuration to the
frontend.
2025-01-28 10:05:37 +01:00
Nathan Panchout
70048328d1 (frontend) update document sharing tests
- Added Share button interactions in various document visibility
scenarios
- Updated test assertions for share and copy link functionality
- Improved test coverage for document sharing features
2025-01-28 08:59:28 +01:00
Nathan Panchout
55ddfe9181 💄(frontend) refactor document sharing and grid components
Improvements:
- Added disabled state for dropdown menus in share settings
- Updated document grid layout and responsiveness
- Simplified sharing and access count logic
- Improved tooltips and visibility of shared documents
- Created a new responsive doc grid hook
2025-01-28 08:59:28 +01:00
renovate[bot]
ee41d156c7 ⬆️(dependencies) update django to v5.1.5 [SECURITY] 2025-01-27 10:06:16 +01:00
Manuel Raynaud
5be2bc7360 ♻️(actions) create a reusable workflow to install front dependencies
In more than one workflow we need to install frontend dependencies and
this 3 workflows we are copy/pasting the same code. We want to refactor
this by creating a reusable workflow
https://docs.github.com/en/actions/sharing-automations/reusing-workflows
2025-01-24 12:22:48 +01:00
Manuel Raynaud
e46ba4f506 🌐(back) update source translations
In order to have a repo correctly confiogured to managed translation
with crowdin, source translations for the backend app are updated
2025-01-24 12:22:48 +01:00
Manuel Raynaud
7c8b969fa9 🔥(back) remove compiled translation file
The compiles translation file should not be track with git.
2025-01-24 12:22:48 +01:00
Manuel Raynaud
95515fd460 🌐(action) create a workflow to download translation
A new workflow is added responsible todownload new translated strings
and then create a pull request to update the code.
2025-01-24 12:22:48 +01:00
Manuel Raynaud
ce6cfc22ef 🌐(action) upload sources translation on crowdin
Crowdin has released its own github action to automatize translation
workflow. We want to use to upload sources when a PR is merged.
2025-01-24 12:22:48 +01:00
virgile-dev
4b3b441fc3 📝(project) update readme following upgrade to v2
Remove old mentions to "impress" following the repository renaming.
Improve and update descriptions to better reflect the status of the
project after release version 2.
2025-01-21 23:39:22 +01:00
Anthony LC
9194bf5a90 🔖(patch) release 2.0.1
Fixed:
🐛(frontend) title copy break app
2025-01-17 11:58:55 +01:00
Anthony LC
dc63a5839e ⬇️(frontend) downgraded blocknote to 0.21.0
The last version of Blocknote (0.22.0) has a bug,
when we copy paste a title, the app sometimes crashes.
Better to downgrade to 0.21.0 until the bug is fixed.
2025-01-17 11:33:47 +01:00
Anthony LC
d406846986 🎨(frontend) format css blocknote editor
We use the "css" function of style components
to format correctly the blocknote editor css.
2025-01-17 11:33:47 +01:00
Nathan Panchout
e85b07021e 🐛(frontend) fix collaboration cursor
- The collaboration slider is not fully shown when a user is at the very
top of the document
2025-01-17 11:02:41 +01:00
Nathan Panchout
282200ac3d 🐛(frontend) hide the sharing method when you don't have the rights
- Added a new hook `useCopyDocLink` to handle copying document links to
the clipboard with success/error notifications.
- Updated the `DocToolBox`, `DocsGridActions`, and `DocShareModal`
components to utilize the new copy link feature.
- Enhanced tests to verify the functionality of the copy link button in
various scenarios.
- Adjusted visibility checks for sharing options based on user access
rights.
2025-01-17 11:02:41 +01:00
Anthony LC
de8dea20d5 🔖(major) release 2.0.0
Added:
- 🔧(backend) add option to configure list of
essential OIDC claims
- 🔧(helm) add option to disable default tls
setting by @dominikkaminski
- 💄(frontend) Add left panel
- 💄(frontend) add filtering to left panel
- (frontend) new share modal ui
- (frontend) add favorite feature

Changed:
- 🏗️(yjs-server) organize yjs server
- ♻️(frontend) better separation collaboration
process
- 💄(frontend) updating the header and leftpanel
for responsive
- 💄(frontend) update DocsGrid component
- 💄(frontend) update DocsGridOptions component
- 💄(frontend) update DocHeader ui
- 💄(frontend) update doc versioning ui
- 💄(frontend) update doc summary u

Fixed:
- 🐛(backend) fix create document via s2s
if sub unknown but email found
- 🐛(frontend) hide search and create doc
button if not authenticated
- 🐛(backend) race condition creation issue
2025-01-15 12:46:00 +01:00
Anthony LC
342fc2ab59 ✏️(backend) fix read_only_fields is_favorite
is_favorite has a typo error.
This commit fixes it.
2025-01-15 12:13:40 +01:00
Anthony LC
b8132ef393 🐛(backend) creation race condition
3 requests we able to create a document:
- POST document request
- GET collaboration-auth
- GET media-auth

If the 2 last were faster than the first, a
document was created without the necessary
informations.
2025-01-15 12:13:40 +01:00
Nathan Panchout
2ede746d8a (frontend) hide search and create doc button if not logged
- Added visibility checks for 'search' and 'New doc' buttons in the
document visibility tests.
- Updated LeftPanelHeader to conditionally render 'search' and 'New doc'
buttons based on user authentication status, improving user experience
and access control.
2025-01-15 12:00:40 +01:00
Anthony LC
5bd0764bdd 🌐(frontend) add last translations
Add the missing translations FR / DE.
2025-01-14 17:38:19 +01:00
Samuel Paccoud - DINUM
610948cd16 🐛(backend) fix create document for user when sub does not match
When creating a document on behalf of a user via the server-to-server
API, a special edge case was broken that should should never happen
but happens in our OIDC federation because one of the provider modifies
the users "sub" each time they login.

We end-up with existing users for who the email matches but not the sub.
They were not correctly handled.

I made a few additional fixes and improvements to the endpoint.
2025-01-14 16:18:14 +01:00
Samuel Paccoud - DINUM
96bb99d6ec 🐛(compose) fix "port already taken" errors when starting docker compose
We have changed the project's name from "impress" to "docs" but haven't
replaced all occurrences of impress in the project because we want to be
careful of the consequences on deployments.

The name of the docker compose project was different for the "make pylint"
target. This was causing the bug error on ports. Let's rename it without
waiting.
2025-01-14 16:18:14 +01:00
131 changed files with 3403 additions and 909 deletions

76
.github/workflows/crowdin_download.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Download translations from Crowdin
on:
workflow_dispatch:
push:
branches:
- 'release/**'
jobs:
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
synchronize-with-crowdin:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create empty source files
run: |
touch src/backend/locale/django.pot
mkdir -p src/frontend/packages/i18n/locales/impress/
touch src/frontend/packages/i18n/locales/impress/translations-crowdin.json
# crowdin workflow
- name: crowdin action
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: false
push_translations: false
push_sources: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: "../src/"
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: generate translations files
working-directory: src/frontend
run: yarn i18n:deploy
# Create a new PR
- name: Create a new Pull Request with new translated strings
uses: peter-evans/create-pull-request@v7
with:
commit-message: |
🌐(i18n) update translated strings
Update translated files with new translations
title: 🌐(i18n) update translated strings
body: |
## Purpose
update translated strings
## Proposal
- [x] update translated strings
branch: i18n/update-translations
labels: i18n

67
.github/workflows/crowdin_upload.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Update crowdin sources
on:
workflow_dispatch:
push:
branches:
- main
jobs:
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
synchronize-with-crowdin:
needs: install-front
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Backend i18n
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .
working-directory: src/backend
- name: Install gettext
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
- name: generate pot files
working-directory: src/backend
run: |
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: generate source translation file
working-directory: src/frontend
run: yarn i18n:extract
# crowdin workflow
- name: crowdin action
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: "../src/"

View File

@@ -0,0 +1,36 @@
name: Install frontend installation reusable workflow
on:
workflow_call:
inputs:
node_version:
required: false
default: '20.x'
type: string
jobs:
front-dependencies-installation:
runs-on: ubuntu-latest
steps:
- name: Checkout
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: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}

View File

@@ -9,39 +9,15 @@ on:
- "*"
jobs:
install-front:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- 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: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
test-front:
runs-on: ubuntu-latest
needs: install-front
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -53,10 +29,10 @@ jobs:
- 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') }}
fail-on-cache-miss: true
- name: Test App
run: cd src/frontend/ && yarn test
@@ -68,29 +44,39 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- 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') }}
fail-on-cache-miss: true
- name: Check linting
run: cd src/frontend/ && yarn lint
test-e2e-chromium:
runs-on: ubuntu-latest
needs: install-front
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- 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') }}
fail-on-cache-miss: true
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
@@ -141,12 +127,8 @@ jobs:
- 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: Install frontend dependencies
uses: ./.github/workflows/front-dependencies-installation.yml
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist

View File

@@ -206,10 +206,11 @@ jobs:
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install gettext (required to compile messages)
- name: Install gettext (required to compile messages) and MIME support
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
sudo apt-get install -y gettext pandoc shared-mime-info
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages

2
.gitignore vendored
View File

@@ -30,6 +30,7 @@ MANIFEST
.next/
# Translations # Translations
*.mo
*.pot
# Environments
@@ -40,6 +41,7 @@ ENV/
env.bak/
venv.bak/
env.d/development/*
env.d/production/*
!env.d/development/*.dist
env.d/terraform

View File

@@ -11,6 +11,26 @@ and this project adheres to
## Added
- github actions to managed Crowdin workflow
- 📈Integrate Posthog #540
- 🏷️(backend) add content-type to uploaded files #552
## Changed
- 💄(frontend) add abilities on doc row #581
## [2.0.1] - 2025-01-17
## Fixed
-🐛(frontend) share modal is shown when you don't have the abilities #557
-🐛(frontend) title copy break app #564
## [2.0.0] - 2025-01-13
## Added
- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
- 💄(frontend) Add left panel #420
@@ -25,10 +45,16 @@ and this project adheres to
- 💄(frontend) updating the header and leftpanel for responsive #421
- 💄(frontend) update DocsGrid component #431
- 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #446
- 💄(frontend) update DocHeader ui #448
- 💄(frontend) update doc versioning ui #463
- 💄(frontend) update doc summary ui #473
- 📝(doc) update readme.md to match V2 changes #558
## Fixed
- 🐛(backend) fix create document via s2s if sub unknown but email found #543
- 🐛(frontend) hide search and create doc button if not authenticated #555
- 🐛(backend) race condition creation issue #556
## [1.10.0] - 2024-12-17
@@ -197,7 +223,7 @@ and this project adheres to
- 🛂(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 unauthenticated #292
- 🛂(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
@@ -326,7 +352,7 @@ and this project adheres to
- ⚡️(e2e) unique login between tests (#80)
- ⚡️(CI) improve e2e job (#86)
- ♻️(frontend) improve the error and message info ui (#93)
- ✏️(frontend) change all occurrences of pad to doc (#99)
- ✏️(frontend) change all occurences of pad to doc (#99)
## Fixed
@@ -346,7 +372,9 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.10.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.0.1...main
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2

View File

@@ -51,7 +51,7 @@ COPY ./src/backend /app/
WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
@@ -76,6 +76,8 @@ RUN apk add \
pango \
shared-mime-info
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -92,6 +94,11 @@ COPY ./src/backend /app/
WORKDIR /app
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.

View File

@@ -38,13 +38,13 @@ DB_PORT = 5432
DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE = DOCKER_USER=$(DOCKER_USER) ./bin/compose
COMPOSE_PRODUCTION = DOCKER_USER=$(DOCKER_USER) COMPOSE_FILE=compose.production.yaml ./bin/compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -65,6 +65,19 @@ data/media:
data/static:
@mkdir -p data/static
# -- production volumes
data/production/media:
@mkdir -p data/production/media
data/production/certs:
@mkdir -p data/production/certs
data/production/databases/backend:
@mkdir -p data/production/databases/backend
data/production/databases/keycloak:
@mkdir -p data/production/databases/keycloak
# -- Project
create-env-files: ## Copy the dist env files to env files
@@ -89,6 +102,27 @@ bootstrap: \
mails-build
.PHONY: bootstrap
bootstrap-production: ## Prepare project to run in production mode using docker compose
bootstrap-production: \
env.d/production \
data/production/media \
data/production/certs \
data/production/databases/backend \
data/production/databases/keycloak
bootstrap-production:
@echo 'Environment files created in env.d/production'
@echo 'Edit them to set good value for your production environment'
.PHONY: bootstrap-production
run-production: ## Run compose project in production mode
@$(COMPOSE_PRODUCTION) up -d ingress
.PHONY: run-production
stop-production: ## Stop compose project in production mode
@$(COMPOSE_PRODUCTION) stop
.PHONY: stop-production
# -- Docker/compose
build: cache ?= --no-cache
build: ## build the project containers
@@ -124,8 +158,6 @@ run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d nginx
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@@ -188,14 +220,12 @@ test-back-parallel: ## run all back-end tests in parallel
makemigrations: ## run django makemigrations for the impress project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations
.PHONY: makemigrations
migrate: ## run django migrations for the impress project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate
.PHONY: migrate
@@ -229,6 +259,8 @@ resetdb: ## flush database and create a superuser "admin"
@${MAKE} superuser
.PHONY: resetdb
# -- Environment variable files
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
@@ -238,6 +270,9 @@ env.d/development/postgresql:
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
env.d/production:
cp -rnf env.d/production.dist env.d/production
# -- Internationalization
env.d/development/crowdin:

160
README.md
View File

@@ -1,113 +1,171 @@
# Impress
<p align="center">
<a href="https://github.com/suitenumerique/docs">
<img alt="Docs" src="/docs/assets/logo-docs.png" width="300" />
</a>
</p>
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
<p align="center">
Welcome to Docs! The open source document editor where your notes can become knowledge through live collaboration
</p>
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/)
<p align="center">
<a href="https://matrix.to/#/#docs-official:matrix.org">
Chat on Matrix
</a> - <a href="/docs/">
Documentation
</a> - <a href="#getting-started">
Getting started
</a>
</p>
## Getting started
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
### Prerequisite
## Why use Docs ❓
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
Make sure you have a recent version of Docker and [Docker
Compose](https://docs.docker.com/compose/install) installed on your laptop:
### Write
* 😌 Simple collaborative editing without the formatting complexity of markdown
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
* 🧱 Built for productivity (markdown support, many block types, slash commands, markdown support, keyboard shortcuts) (page in french sorry 😅).
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
```bash
### Collaborate
* 🤝 Collaborate in realtime with your team mates
* 🔒 Granular access control to keep your information secure and shared with the right people
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
* 📚 Built-in wiki functionality to transform your team's collaborative work into organized knowledge `ETA 02/2025`
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
## Getting started 🔧
### Test it
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
```
email: test.docs@yopmail.com
password: I'd<3ToTestDocs
```
### Run it locally
**Prerequisite**
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
```shellscript
$ docker -v
Docker version 20.10.2, build 2291f61
Docker version 27.4.1, build b9d17ea
$ docker compose -v
docker compose version 1.27.4, build 40524192
$ docker compose version
Docker Compose version v2.32.1
```
> ⚠️ You may need to run the following commands with `sudo` but this can be
> avoided by assigning your user to the `docker` group.
### Project bootstrap
> ⚠️ You may need to run the following commands with sudo but this can be avoided by assigning your user to the `docker` group.
**Project bootstrap**
The easiest way to start working on the project is to use GNU Make:
```bash
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
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
dependency-releated or migration-releated issues.
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 dependency-releated or migration-releated issues.
Your Docker services should now be up and running 🎉
You can access to the project by going to http://localhost:3000.
You can access to the project by going to <http://localhost:3000>.
You will be prompted to log in, the default credentials are:
```bash
```shellscript
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```bash
```shellscript
$ make run-with-frontend
```
---
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
⚠️ 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
```shellscript
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
```bash
```shellscript
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```bash
```shellscript
$ make run
```
---
### Adding content
**Adding content**
You can create a basic demo site by running:
$ make demo
```shellscript
$ make demo
```
Finally, you can check all available Make rules using:
```bash
```shellscript
$ make help
```
### Django admin
**Django admin**
You can access the Django admin site at
[http://localhost:8071/admin](http://localhost:8071/admin).
<http://localhost:8071/admin>.
You first need to create a superuser account:
```bash
```shellscript
$ make superuser
```
## Contributing
## Feedback 🙋‍♂️🙋‍♀️
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
This project is intended to be community-driven, so please, do not hesitate to
get in touch if you have any question related to our implementation or design
decisions.
## Roadmap
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
## License
## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
This work is released under the MIT License (see [LICENSE](./LICENSE)).
While Docs is public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
## Contributing 🙌
This project is intended to be community-driven, so please, do not hesitate to get in touch if you have any question related to our implementation or design decisions.
If you intend to make pull requests see CONTRIBUTING for guidelines.
Directory structure:
```markdown
docs
├── bin - executable scripts or binaries that are used for various tasks, such as setup scripts, utility scripts, or custom commands.
├── crowdin - for crowdin translations, a tool or service that helps manage translations for the project.
├── docker - Dockerfiles and related configuration files used to build Docker images for the project. These images can be used for development, testing, or production environments.
├── docs - documentation for the project, including user guides, API documentation, and other helpful resources.
├── env.d/development - environment-specific configuration files for the development environment. These files might include environment variables, configuration settings, or other setup files needed for development.
├── gitlint - configuration files for `gitlint`, a tool that enforces commit message guidelines to ensure consistency and quality in commit messages.
├── playground - experimental or temporary code, where developers can test new features or ideas without affecting the main codebase.
└── src - main source code directory, containing the core application code, libraries, and modules of the project.
```
## Credits ❤️
### Stack
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/) and [BlocNote.js](https://www.blocknotejs.org/)
### States ❤️ open source
Docs is the result of a joint effort lead by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 government ([ZenDiS](https://zendis.de/)). We are always looking for new public partners feel free to reach out if you are interested in using or contributing to docs.

View File

@@ -6,9 +6,9 @@ REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="impress"
if [ -z ${COMPOSE_FILE+x} ]; then
COMPOSE_FILE="${REPO_DIR}/compose.yaml"
fi
# _set_user: set (or unset) default user id used to run docker commands
#
@@ -40,9 +40,8 @@ function _set_user() {
# ARGS : docker compose command arguments
function _docker_compose() {
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
echo "🐳(compose) project, file: '${COMPOSE_FILE}'"
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \
"$@"

17
bin/update_app_cacert.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -o errexit
# The script is pretty simple. It downloads the latest cacert.pem file from the certifi package and appends the root certificate from mkcert to it. Then it copies the updated cacert.pem file to the container.
# The script is executed with the following command:
# $ bin/update_app_cacert.sh docs-production-backend-1
CONTAINER_NAME=${1:-"docs-production-backend-1"}
echo "updating cacert.pem for certifi package in ${CONTAINER_NAME}"
curl --create-dirs https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/certifi/cacert.pem
cat "$(mkcert -CAROOT)/rootCA.pem" >> /tmp/certifi/cacert.pem
docker cp /tmp/certifi/cacert.pem ${CONTAINER_NAME}:/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
echo "end patching cacert.pem in ${CONTAINER_NAME}"

167
compose.production.yaml Normal file
View File

@@ -0,0 +1,167 @@
name: docs-production
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD", "pg_isready", "-q", "-U", "docs", "-d", "docs"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/production/postgresql
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/production/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:5
backend-migration:
image: lasuite/impress-backend:latest
user: ${DOCKER_USER:-1000}
command: ["python", "manage.py", "migrate", "--noinput"]
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
backend:
image: lasuite/impress-backend:latest
user: ${DOCKER_USER:-1000}
restart: always
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 15s
timeout: 30s
retries: 20
start_period: 10s
depends_on:
postgresql:
condition: service_healthy
restart: true
backend-migration:
condition: service_completed_successfully
redis:
condition: service_started
minio:
condition: service_started
minio-bootstrap:
condition: service_completed_successfully
celery:
user: ${DOCKER_USER:-1000}
image: lasuite/impress-backend:latest
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
depends_on:
- backend
frontend:
image: lasuite/impress-frontend:latest
user: ${DOCKER_USER:-1000}
y-provider:
image: lasuite/impress-y-provider:latest
user: ${DOCKER_USER:-1000}
env_file:
- env.d/production/yprovider
kc_postgresql:
image: postgres:16
healthcheck:
test: ["CMD", "pg_isready", "-q", "-U", "keycloak", "-d", "keycloak"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/production/kc_postgresql
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/production/databases/keycloak:/var/lib/postgresql/data/pgdata
keycloak:
image: quay.io/keycloak/keycloak:26.1.0
command: ["start"]
env_file:
- env.d/production/keycloak
- env.d/production/kc_postgresql
ports:
- "8443:8443"
volumes:
- ${DOCS_PROD_KEYCLOAK_CERT_FOLDER:-./data/production/certs}:/etc/ssl/certs:ro
depends_on:
kc_postgresql:
condition: service_healthy
restart: true
minio-bootstrap:
image: minio/mc
env_file:
- env.d/production/minio
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set docs http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD} && \
/usr/bin/mc mb --ignore-existing docs/docs-media-storage && \
/usr/bin/mc version enable docs/docs-media-storage && \
exit 0;"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
env_file:
- env.d/production/minio
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server /data
volumes:
- ./data/production/media:/data
ingress:
image: nginx:1.27
ports:
- "${DOCS_PROD_NGING_PORT:-443}:8083"
volumes:
- ./docker/files/production/etc/nginx/conf.d:/etc/nginx/conf.d:ro
- ${DOCS_PROD_NGINX_CERT_FOLDER:-./data/production/certs}:/etc/nginx/ssl:ro
depends_on:
frontend:
condition: service_started
y-provider:
condition: service_started
keycloak:
condition: service_started
backend:
condition: service_healthy
restart: true

View File

@@ -1,6 +1,13 @@
name: docs
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/development/postgresql
ports:
@@ -23,6 +30,11 @@ services:
ports:
- '9000:9000'
- '9001:9001'
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
@@ -31,7 +43,9 @@ services:
createbuckets:
image: minio/mc
depends_on:
- minio
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set impress http://minio:9000 impress password && \
@@ -59,10 +73,15 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
- postgresql
- mailcatcher
- redis
- createbuckets
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
createbuckets:
condition: service_started
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -93,9 +112,13 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
- postgresql
- redis
- minio
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
celery:
user: ${DOCKER_USER:-1000}
@@ -135,9 +158,6 @@ services:
ports:
- "3000:3000"
dockerize:
image: jwilder/dockerize
crowdin:
image: crowdin/cli:3.16.0
volumes:
@@ -151,7 +171,7 @@ services:
image: node:18
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
HOME: /tmp
volumes:
- ".:/app"
@@ -169,6 +189,11 @@ services:
kc_postgresql:
image: postgres:14.3
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
ports:
- "5433:5432"
env_file:
@@ -200,4 +225,6 @@ services:
ports:
- "8080:8080"
depends_on:
- kc_postgresql
kc_postgresql:
condition: service_healthy
restart: true

View File

@@ -1,7 +1,7 @@
#
# Your crowdin's credentials
#
api_token_env: CROWDIN_API_TOKEN
api_token_env: CROWDIN_PERSONAL_TOKEN
project_id_env: CROWDIN_PROJECT_ID
base_path_env: CROWDIN_BASE_PATH
@@ -15,11 +15,11 @@ preserve_hierarchy: true
# Files configuration
#
files: [
{
source : "/backend/locale/django.pot",
dest: "/backend-impress.pot",
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
},
{
source : "/backend/locale/django.pot",
dest: "/backend-impress.pot",
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
},
{
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
dest: "/frontend-impress.json",

View File

@@ -0,0 +1,132 @@
upstream docs_backend {
server backend:8000 fail_timeout=0;
}
upstream docs_frontend {
server frontend:8080 fail_timeout=0;
}
server {
listen 8083 ssl;
server_name localhost;
# Disables server version feedback on pages and in headers
server_tokens off;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
location @proxy_to_docs_backend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_backend;
}
location @proxy_to_docs_frontend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_frontend;
}
location / {
try_files $uri @proxy_to_docs_frontend;
}
location /api {
try_files $uri @proxy_to_docs_backend;
}
location /admin {
try_files $uri @proxy_to_docs_backend;
}
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Collaboration Auth request configuration
auth_request /collaboration-auth;
proxy_set_header X-Forwarded-Proto https;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $canEdit $upstream_http_x_can_edit;
auth_request_set $userId $upstream_http_x_user_id;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Can-Edit $canEdit;
proxy_set_header X-User-Id $userId;
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://y-provider:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Origin $http_origin;
proxy_set_header Host $host;
}
location /collaboration-auth {
proxy_pass http://docs_backend/api/v1.0/documents/collaboration-auth/;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/docs-media-storage/;
proxy_set_header Host minio:9000;
}
location /media-auth {
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

BIN
docs/assets/logo-docs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -40,6 +40,7 @@ backend:
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.impress.127.0.0.1.nip.io'}"
DB_HOST: postgresql
DB_NAME: impress
DB_USER: dinum
@@ -121,6 +122,12 @@ yProvider:
COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret
posthog:
ingress:
enabled: false
ingressAssets:
enabled: false
ingress:
enabled: true
host: impress.127.0.0.1.nip.io

View File

@@ -0,0 +1,66 @@
# Installation with docker compose
We provide a configuration for running Docs in production using docker compose. This configuration is experimental, the official way to deploy Docs in production is to use [k8s](docs/installation/k8s.md)
## Requirements
- A modern version of Docker and its Compose plugin.
- SSL certificates for Docs domain and Keycloak.
- Two domain name. One for the Docs application and an other one for Keycloak. Both can be a subdomain of a common domain. (example: docs.domain.tld and keycloak.domain.tld)
## Installation
- Clone this repository: `git clone https://github.com/suitenumerique/docs.git`
- Then in the clone directory you can run the following command: `make bootsrap-production`
## Configure your ssl certificates
You have to provide the ssl certificates. The easiest way is to use [certbot](https://certbot.eff.org/), generate the certificates with it (both for Docs and Keycloak) and then mount them in ingress and keycloak containers. Two environment variables can be used for that:
- `DOCS_PROD_NGINX_CERT_FOLDER` path to the folder containing the certificates for Docs. This folder will be mounted in `/etc/nginx/ssl` in the container. You have to adapt the certificates name in the file `docker/files/production/etc/nginx/conf.d/default.conf` accordingly with the certificates name you have (see `ssl_certificate` and `ssl_certificate_key` directives).
- `DOCS_PROD_KEYCLOAK_CERT_FOLDER` path to the folder containing the certificates for Keycloak. This folder will be mounted in `/etc/ssl/certs` in the container. You have to adapt the certificates name in the configuration file in `env.d/production/keycloak` to add the correct path for environment variables `KC_HTTPS_CERTIFICATE_FILE` and `KC_HTTPS_CERTIFICATE_KEY_FILE`.
### Configuration
All the configuration files are in the directory `env.d/production`. You have to edit all the files to complete them. For the OIDC information you will have them once Keycloak will be running and you have configured your own realm on it.
#### env.d/production/minio
All the settings related to Minio. You have to set a username and a password to manage the minio cluster. You will need them later in the `env.d/production/backend` file.
#### env.d/production/postgresql
All the settings related to the Postgresql database used by the Django application.
#### env.d/production/yprovider
All the settings related to the collaboration server. All the secret and api key must be generated.
#### env.d/production/kc_postgresql
All the settings related to the Postgresql database used by keycloak.
#### env.d/production/keycloak
All the settings related to the Keycloak application.
#### env.d/production/backend
All the settings related to the Django application. Only the settings you don't have for now are all the one related to OIDC. You will have them once the compose started and you can access to Keycloak.
## Run the compose configuration
The compose configuration can be run with the following command: `make run-production`. The first start can be a little bit long, lots of things are created. Once started you can check that everything is running with the following command: `COMPOSE_FILE=compose.production.yaml ./bin/compose ps`
## Configure keycloak
You have to create a new realm in your Keycloak and once created you have to create a new OIDC client in it. You will use this client to configure the OIDC part in `env.d/production/backend`. This is the last missing part to complete the Django application configuration.
Once the client information set in `env.d/production/backend` you have to start the containers again by running the commande `make run-production`. The command will recreate the containers with the good configuration.
### Helpers
there is a helper script to control the `docker compose` command. You can export the variable `COMPOSE_FILE` with the compose filename (`export COMPOSE_FILE=compose.production.yaml`). After you can run `./bin/compose` to run the docker compose command line.
Makefile commands available:
- `make bootstrap-production`: create the configuration files in `env.d/production`, create the directories : `data/production`. Both directories must be backup, if you loose them you loose all the data related to the application.
- `make run-production`: up the ingress containers. Will start all the containers needed in cascade.
- `make stop-production`: stop all the containers.

View File

@@ -6,7 +6,7 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each project (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml

View File

@@ -1,3 +1,3 @@
CROWDIN_API_TOKEN=Your-Api-Token
CROWDIN_PERSONAL_TOKEN=Your-Personal-Token
CROWDIN_PROJECT_ID=Your-Project-Id
CROWDIN_BASE_PATH=/app/src

View File

@@ -0,0 +1,58 @@
## Django
DJANGO_ALLOWED_HOSTS=impress.127.0.0.1.nip.io,keycloack.127.0.0.1.nip.io
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_SUPERUSER_PASSWORD=ThisIsAnExamplePassword
# Logging
# Set to DEBUG level for dev only
LOGGING_LEVEL_HANDLERS_CONSOLE=ERROR
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Python
PYTHONPATH=/app
# impress settings
# Mail
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_LOGO_IMG="https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=1025
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=<minio root user>
AWS_S3_SECRET_ACCESS_KEY=<minio root password>
AWS_STORAGE_BUCKET_NAME=docs-media-storage
MEDIA_BASE_URL=impress.127.0.0.1.nip.io
# OIDC
USER_OIDC_FIELD_TO_SHORTNAME="given_name"
USER_OIDC_FIELDS_TO_FULLNAME="given_name,usual_name"
OIDC_OP_JWKS_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID=impress
OIDC_RP_CLIENT_SECRETThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
LOGIN_REDIRECT_URL=https://impress.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE=https://impress.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL=https://impress.127.0.0.1.nip.io
OIDC_REDIRECT_ALLOWED_HOSTS=["https://impress.127.0.0.1.nip.io"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# AI
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Frontend
FRONTEND_THEME=dsfr

View File

@@ -0,0 +1,9 @@
# Postgresql db container configuration
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<Set postgresql password>
# Keycloak database configuration
KC_DB_URL_DATABASE=keycloak
KC_DB_USERNAME=keycloak
KC_DB_PASSWORD=<Same password as above>

View File

@@ -0,0 +1,9 @@
KC_BOOTSTRAP_ADMIN_USERNAME=<Change this admin user>
KC_BOOTSTRAP_ADMIN_PASSWORD=<Change this admin password>
KC_DB=postgres
KC_DB_URL_HOST=kc_postgresql
KC_DB_SCHEMA=public
PROXY_ADDRESS_FORWARDING='true'
KC_HOSTNAME=http://localhost:8083
KC_HTTPS_CERTIFICATE_FILE=/etc/ssl/certs/docs.crt
KC_HTTPS_CERTIFICATE_KEY_FILE=/etc/ssl/private/docs.key

View File

@@ -0,0 +1,2 @@
MINIO_ROOT_USER=<Set minio root username>
MINIO_ROOT_PASSWORD=<Set minio root password>

View File

@@ -0,0 +1,11 @@
# Postgresql db container configuration
POSTGRES_DB=docs
POSTGRES_USER=docs
POSTGRES_PASSWORD=<Set postgresql password>
# App database configuration
DB_HOST=postgresql
DB_NAME=docs
DB_USER=docs
DB_PASSWORD=<Same password as above>
DB_PORT=5432

View File

@@ -0,0 +1,5 @@
COLLABORATION_LOGGING=true
Y_PROVIDER_API_KEY=<Set y provider api key>
COLLABORATION_API_URL=https://impress.127.0.0.1.nip.io/collaboration/api/
COLLABORATION_SERVER_ORIGIN=https://impress.127.0.0.1.nip.io
COLLABORATION_SERVER_SECRET=<Set collaboration secret>

View File

@@ -201,7 +201,7 @@ class DocumentSerializer(ListDocumentSerializer):
"abilities",
"created_at",
"creator",
"is_avorite",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
@@ -264,13 +264,17 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
"""Create the document and associate it with the user or send an invitation."""
language = validated_data.get("language", settings.LANGUAGE_CODE)
# Get the user based on the sub (unique identifier)
# Get the user on its sub (unique identifier). Default on email if allowed in settings
email = validated_data["email"]
try:
user = models.User.objects.get(sub=validated_data["sub"])
except (models.User.DoesNotExist, KeyError):
user = None
email = validated_data["email"]
else:
user = models.User.objects.get_user_by_sub_or_email(
validated_data["sub"], email
)
except models.DuplicateEmailError as err:
raise serializers.ValidationError({"email": [err.message]}) from err
if user:
email = user.email
language = user.language or language
@@ -279,7 +283,9 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
validated_data["content"]
)
except ConversionError as err:
raise exceptions.APIException(detail="could not convert content") from err
raise serializers.ValidationError(
{"content": ["Could not convert content"]}
) from err
document = models.Document.objects.create(
title=validated_data["title"],
@@ -302,7 +308,11 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
role=models.RoleChoices.OWNER,
)
# Notify the user about the newly created document
self._send_email_notification(document, validated_data, email, language)
return document
def _send_email_notification(self, document, validated_data, email, language):
"""Notify the user about the newly created document."""
subject = validated_data.get("subject") or _(
"A new document was created on your behalf!"
)
@@ -313,8 +323,6 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
}
document.send_email(subject, [email], context, language)
return document
def update(self, instance, validated_data):
"""
This serializer does not support updates.
@@ -380,6 +388,7 @@ class FileUploadSerializer(serializers.Serializer):
raise serializers.ValidationError("Could not determine file extension.")
self.context["expected_extension"] = extension
self.context["content_type"] = magic_mime_type
return file
@@ -387,6 +396,7 @@ class FileUploadSerializer(serializers.Serializer):
"""Override validate to add the computed extension to validated_data."""
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
attrs["content_type"] = self.context["content_type"]
return attrs

View File

@@ -140,7 +140,6 @@ class UserViewSet(
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all()
serializer_class = serializers.UserSerializer
ordering = ["-created_at"]
def get_queryset(self):
"""
@@ -606,7 +605,10 @@ class DocumentViewSet(
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
# Prepare metadata for storage
extra_args = {"Metadata": {"owner": str(request.user.id)}}
extra_args = {
"Metadata": {"owner": str(request.user.id)},
"ContentType": serializer.validated_data["content_type"],
}
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
@@ -630,7 +632,7 @@ class DocumentViewSet(
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged-in user, we must decide if we authorize Nginx
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
@@ -677,7 +679,7 @@ class DocumentViewSet(
# Fetch the document and check if the user has access
try:
document, _created = models.Document.objects.get_or_create(pk=pk)
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as exc:
logger.debug("Document with ID '%s' does not exist", pk)
raise drf.exceptions.PermissionDenied() from exc
@@ -835,7 +837,7 @@ class DocumentAccessViewSet(
serializer_class = serializers.DocumentAccessSerializer
def perform_create(self, serializer):
"""Add new access to the document and email the new added user."""
"""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")
@@ -847,7 +849,7 @@ class DocumentAccessViewSet(
)
def perform_update(self, serializer):
"""Update access to the document and notify the collaboration server."""
"""Update an access to the document and notify the collaboration server."""
access = serializer.save()
access_user_id = None
@@ -860,7 +862,7 @@ class DocumentAccessViewSet(
)
def perform_destroy(self, instance):
"""Delete access to the document and notify the collaboration server."""
"""Delete an access to the document and notify the collaboration server."""
instance.delete()
# Notify collaboration server about the access removed
@@ -1099,7 +1101,7 @@ class InvitationViewset(
return queryset
def perform_create(self, serializer):
"""Save invitation to a document then email the invited user."""
"""Save invitation to a document then send an email to the invited user."""
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
@@ -1125,6 +1127,7 @@ class ConfigView(drf.views.APIView):
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",

View File

@@ -11,7 +11,7 @@ from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from core.models import User
from core.models import DuplicateEmailError, User
logger = logging.getLogger(__name__)
@@ -98,7 +98,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"short_name": short_name,
}
user = self.get_existing_user(sub, email)
try:
user = User.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
if user:
if not user.is_active:
@@ -117,16 +120,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
return full_name or None
def get_existing_user(self, sub, email):
"""Fetch an existing user by sub (or email as a fallback respecting fallback setting."""
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
return User.objects.filter(email=email).first()
return None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(

View File

@@ -2,7 +2,7 @@
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
@@ -14,5 +14,5 @@ urlpatterns = [
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozilla_oidc_urls,
*mozzila_oidc_urls,
]

View File

@@ -19,7 +19,6 @@ class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.User
skip_postgeneration_save = True
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
@@ -37,8 +36,6 @@ class UserFactory(factory.django.DjangoModelFactory):
if create and (extracted is True):
UserDocumentAccessFactory(user=self, role="owner")
self.save()
@factory.post_generation
def with_owned_template(self, create, extracted, **kwargs):
"""
@@ -48,8 +45,6 @@ class UserFactory(factory.django.DjangoModelFactory):
if create and (extracted is True):
UserTemplateAccessFactory(user=self, role="owner")
self.save()
class DocumentFactory(factory.django.DjangoModelFactory):
"""A factory to create documents"""

View File

View File

@@ -0,0 +1,95 @@
"""Management command updating the metadata for all the files in the MinIO bucket."""
from django.core.files.storage import default_storage
from django.core.management.base import BaseCommand
import magic
from core.models import Document
# pylint: disable=too-many-locals, broad-exception-caught
class Command(BaseCommand):
"""Update the metadata for all the files in the MinIO bucket."""
help = __doc__
def handle(self, *args, **options):
"""Execute management command."""
s3_client = default_storage.connection.meta.client
bucket_name = default_storage.bucket_name
mime_detector = magic.Magic(mime=True)
documents = Document.objects.all()
self.stdout.write(
f"[INFO] Found {documents.count()} documents. Starting ContentType fix..."
)
for doc in documents:
doc_id_str = str(doc.id)
prefix = f"{doc_id_str}/attachments/"
self.stdout.write(
f"[INFO] Processing attachments under prefix '{prefix}' ..."
)
continuation_token = None
total_updated = 0
while True:
list_kwargs = {"Bucket": bucket_name, "Prefix": prefix}
if continuation_token:
list_kwargs["ContinuationToken"] = continuation_token
response = s3_client.list_objects_v2(**list_kwargs)
# If no objects found under this prefix, break out of the loop
if "Contents" not in response:
break
for obj in response["Contents"]:
key = obj["Key"]
# Skip if it's a folder
if key.endswith("/"):
continue
try:
# Get existing metadata
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
# Read first ~1KB for MIME detection
partial_obj = s3_client.get_object(
Bucket=bucket_name, Key=key, Range="bytes=0-1023"
)
partial_data = partial_obj["Body"].read()
# Detect MIME type
magic_mime_type = mime_detector.from_buffer(partial_data)
# Update ContentType
s3_client.copy_object(
Bucket=bucket_name,
CopySource={"Bucket": bucket_name, "Key": key},
Key=key,
ContentType=magic_mime_type,
Metadata=head_resp.get("Metadata", {}),
MetadataDirective="REPLACE",
)
total_updated += 1
except Exception as exc: # noqa
self.stderr.write(
f"[ERROR] Could not update ContentType for {key}: {exc}"
)
if response.get("IsTruncated"):
continuation_token = response.get("NextContinuationToken")
else:
break
if total_updated > 0:
self.stdout.write(
f"[INFO] -> Updated {total_updated} objects for Document {doc_id_str}."
)

View File

@@ -1,5 +1,7 @@
# Generated by Django 5.0.3 on 2024-05-28 20:29
import django.contrib.auth.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
import uuid
@@ -143,7 +145,7 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='documentaccess',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
migrations.AddConstraint(
model_name='invitation',
@@ -159,6 +161,6 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='templateaccess',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.1.4 on 2025-01-13 22:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
]
operations = [
migrations.AlterModelOptions(
name='user',
options={'ordering': ('-created_at',), 'verbose_name': 'user', 'verbose_name_plural': 'users'},
),
]

View File

@@ -1,6 +1,7 @@
"""
Declare and configure the models for the impress core application
"""
# pylint: disable=too-many-lines
import hashlib
import smtplib
@@ -89,6 +90,16 @@ class LinkReachChoices(models.TextChoices):
PUBLIC = "public", _("Public") # Even anonymous users can access the document
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
def __init__(self, message=None, email=None):
"""Set message and email to describe the exception."""
self.message = message
self.email = email
super().__init__(self.message)
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -126,6 +137,35 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
def get_user_by_sub_or_email(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.get(sub=sub)
except self.model.DoesNotExist as err:
if not email:
return None
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email=email)
except self.model.DoesNotExist:
pass
elif (
self.filter(email=email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
_(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
) from err
return None
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
@@ -155,7 +195,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
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
# stores the email used by staff users to log in to the admin site
# stores the email used by staff users to login to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
)
@@ -192,14 +232,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
)
objects = auth_models.UserManager()
objects = UserManager()
USERNAME_FIELD = "admin_email"
REQUIRED_FIELDS = []
class Meta:
db_table = "impress_user"
ordering = ("-created_at",)
verbose_name = _("user")
verbose_name_plural = _("users")
@@ -696,7 +735,7 @@ class DocumentAccess(BaseAccess):
violation_error_message=_("This team is already in this document."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
check=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_document_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
@@ -761,7 +800,7 @@ class Template(BaseModel):
"""
document_html = weasyprint.HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html("{}", body_html), **metadata})
Context({"body": html.format_html(body_html), **metadata})
)
)
css = weasyprint.CSS(
@@ -780,7 +819,7 @@ class Template(BaseModel):
Generate and return a docx document wrapped around the current template
"""
template_string = DjangoTemplate(self.code).render(
Context({"body": html.format_html("{}", body_html), **metadata})
Context({"body": html.format_html(body_html), **metadata})
)
html_string = f"""
@@ -798,6 +837,7 @@ class Template(BaseModel):
"""
reference_docx = "core/static/reference.docx"
output = BytesIO()
# Convert the HTML to a temporary docx file
with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file:
@@ -884,7 +924,7 @@ class TemplateAccess(BaseAccess):
violation_error_message=_("This team is already in this template."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
check=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_template_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
@@ -939,7 +979,10 @@ class Invitation(BaseModel):
super().clean()
# Check if an identity already exists for the provided email
if User.objects.filter(email=self.email).exists():
if (
User.objects.filter(email=self.email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)

View File

@@ -17,7 +17,7 @@ class CollaborationService:
def reset_connections(self, room, user_id=None):
"""
Reset connections of a room in the collaboration server.
Resetting a connection means that the user will be disconnected and will
Reseting a connection means that the user will be disconnected and will
have to reconnect to the collaboration server, with updated rights.
"""
endpoint = "reset-connections"

View File

@@ -1,5 +1,6 @@
"""Unit tests for the Authentication Backends."""
import random
import re
from logging import Logger
from unittest import mock
@@ -64,7 +65,33 @@ def test_authentication_getter_existing_user_via_email(
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
def test_authentication_getter_email_none(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email=None)
def get_userinfo_mocked(*args):
user_info = {"sub": "123"}
if random.choice([True, False]):
user_info["email"] = None
return user_info
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 and email didn'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_no_fallback_to_email_allow_duplicate(
settings, monkeypatch
):
"""
@@ -77,6 +104,7 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
@@ -93,6 +121,39 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
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
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
# Since the sub doesn't match, it should not create a new user
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):

View File

@@ -0,0 +1,50 @@
"""
Unit test for `update_files_content_type_metadata` command.
"""
import uuid
from django.core.files.storage import default_storage
from django.core.management import call_command
import pytest
from core import factories
@pytest.mark.django_db
def test_update_files_content_type_metadata():
"""
Test that the command `update_files_content_type_metadata`
fixes the ContentType of attachment in the storage.
"""
s3_client = default_storage.connection.meta.client
bucket_name = default_storage.bucket_name
# Create files with a wrong ContentType
keys = []
for _ in range(10):
doc_id = uuid.uuid4()
factories.DocumentFactory(id=doc_id)
key = f"{doc_id}/attachments/testfile.png"
keys.append(key)
fake_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..."
s3_client.put_object(
Bucket=bucket_name,
Key=key,
Body=fake_png,
ContentType="text/plain",
Metadata={"owner": "None"},
)
# Call the command that fixes the ContentType
call_command("update_files_content_type_metadata")
for key in keys:
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
assert (
head_resp["ContentType"] == "image/png"
), f"ContentType not fixed, got {head_resp['ContentType']!r}"
# Check that original metadata was preserved
assert head_resp["Metadata"].get("owner") == "None"

View File

@@ -698,7 +698,7 @@ def test_api_document_accesses_delete_administrators_except_owners(
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Users who are administrators in a document should be allowed to delete access
Users who are administrators in a document should be allowed to delete an access
from the document provided it is not ownership.
"""
user = factories.UserFactory()

View File

@@ -285,7 +285,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
assert response.status_code == 404
# 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
# only the current version is available to the user but it is excluded
# from the list
document.content = "new content 1"
document.save()

View File

@@ -134,7 +134,7 @@ def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
"""
Authenticated who are not related to a document should be able to request AI transform
Autenticated who are not related to a document should be able to request AI transform
if the link reach and role permit it.
"""
user = factories.UserFactory()

View File

@@ -154,7 +154,7 @@ def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
"""
Authenticated who are not related to a document should be able to request AI translate
Autenticated who are not related to a document should be able to request AI translate
if the link reach and role permit it.
"""
user = factories.UserFactory()

View File

@@ -64,12 +64,22 @@ def test_api_documents_attachment_upload_anonymous_success():
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(response.json()["file"])
file_path = response.json()["file"]
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": "None"}
assert file_head["ContentType"] == "image/png"
@pytest.mark.parametrize(
"reach, role",
@@ -111,7 +121,7 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
)
def test_api_documents_attachment_upload_authenticated_success(reach, role):
"""
Authenticated who are not related to a document should be able to upload a file
Autenticated who are not related to a document should be able to upload a file
if the link reach and role permit it.
"""
user = factories.UserFactory()
@@ -206,6 +216,7 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id)}
assert file_head["ContentType"] == "image/png"
def test_api_documents_attachment_upload_invalid(client):
@@ -225,7 +236,7 @@ def test_api_documents_attachment_upload_invalid(client):
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceed the maximum size in settings."""
"""The uploaded file should not exceeed the maximum size in settings."""
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
user = factories.UserFactory()
@@ -247,16 +258,18 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
@pytest.mark.parametrize(
"name,content,extension",
"name,content,extension,content_type",
[
("test.exe", b"text", "exe"),
("test", b"text", "txt"),
("test.aaaaaa", b"test", "txt"),
("test.txt", PIXEL, "txt"),
("test.py", b"#!/usr/bin/python", "py"),
("test.exe", b"text", "exe", "text/plain"),
("test", b"text", "txt", "text/plain"),
("test.aaaaaa", b"test", "txt", "text/plain"),
("test.txt", PIXEL, "txt", "image/png"),
("test.py", b"#!/usr/bin/python", "py", "text/plain"),
],
)
def test_api_documents_attachment_upload_fix_extension(name, content, extension):
def test_api_documents_attachment_upload_fix_extension(
name, content, extension, content_type
):
"""
A file with no extension or a wrong extension is accepted and the extension
is corrected in storage.
@@ -287,6 +300,7 @@ def test_api_documents_attachment_upload_fix_extension(name, content, extension)
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == content_type
def test_api_documents_attachment_upload_empty_file():
@@ -335,3 +349,4 @@ def test_api_documents_attachment_upload_unsafe():
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == "application/octet-stream"

View File

@@ -13,6 +13,7 @@ import pytest
from rest_framework.test import APIClient
from core import factories
from core.api.serializers import ServerCreateDocumentSerializer
from core.models import Document, Invitation, User
from core.services.converter_services import ConversionError, YdocConverter
@@ -20,7 +21,7 @@ pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_convert_markdown():
def mock_convert_md():
"""Mock YdocConverter.convert_markdown to return a converted content."""
with patch.object(
YdocConverter,
@@ -169,8 +170,11 @@ def test_api_documents_create_for_owner_invalid_sub():
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_existing(mock_convert_markdown):
"""It should be possible to create a document on behalf of a pre-existing user."""
def test_api_documents_create_for_owner_existing(mock_convert_md):
"""
It should be possible to create a document on behalf of a pre-existing user
by passing their sub and email.
"""
user = factories.UserFactory(language="en-us")
data = {
@@ -189,7 +193,7 @@ def test_api_documents_create_for_owner_existing(mock_convert_markdown):
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -213,10 +217,10 @@ def test_api_documents_create_for_owner_existing(mock_convert_markdown):
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
def test_api_documents_create_for_owner_new_user(mock_convert_md):
"""
It should be possible to create a document on behalf of new users by
passing only their email address.
passing their unknown sub and email address.
"""
data = {
"title": "My Document",
@@ -234,7 +238,7 @@ def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -264,8 +268,190 @@ def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
assert document.creator == user
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True,
)
def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback(
mock_convert_md,
):
"""
It should be possible to create a document on behalf of a pre-existing user for
who the sub was not found if the settings allow it. This edge case should not
happen in a healthy OIDC federation but can be usefull if an OIDC provider modifies
users sub on each login for example...
"""
user = factories.UserFactory(language="en-us")
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator == user
assert document.accesses.filter(user=user, role="owner").exists()
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=False,
)
def test_api_documents_create_for_owner_existing_user_email_no_sub_no_fallback(
mock_convert_md,
):
"""
When a user does not match an existing sub and fallback to matching on email is
not allowed in settings, it should raise an error if the email is already used by
a registered user and duplicate emails are not allowed.
"""
user = factories.UserFactory()
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert response.json() == {
"email": [
(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
]
}
assert mock_convert_md.called is False
assert Document.objects.exists() is False
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 0
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=True,
)
def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplicate(
mock_convert_md,
):
"""
When a user does not match an existing sub and fallback to matching on email is
not allowed in settings, it should be possible to create a new user with the same
email as an existing user if the settings allow it (identification is still done
via the sub in this case).
"""
user = factories.UserFactory()
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator is None
assert document.accesses.exists() is False
invitation = Invitation.objects.get()
assert invitation.email == user.email
assert invitation.role == "owner"
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
# The creator field on the document should be set when the user is created
user = User.objects.create(email=user.email, password="!")
document.refresh_from_db()
assert document.creator == user
@patch.object(ServerCreateDocumentSerializer, "_send_email_notification")
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"], LANGUAGE_CODE="de-de")
def test_api_documents_create_for_owner_with_default_language(
mock_send, mock_convert_md
):
"""The default language from settings should apply by default."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
assert mock_send.call_args[0][3] == "de-de"
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdown):
def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
"""
Test creating a document with a specific language.
Useful if the remote server knows the user's language.
@@ -287,7 +473,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdo
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -302,7 +488,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdo
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_subject_and_message(
mock_convert_markdown,
mock_convert_md,
):
"""It should be possible to customize the subject and message of the invitation email."""
data = {
@@ -323,7 +509,7 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -336,11 +522,11 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_converter_exception(
mock_convert_markdown,
mock_convert_md,
):
"""It should be possible to customize the subject and message of the invitation email."""
"""In case of converter error, a 400 error should be raised."""
mock_convert_markdown.side_effect = ConversionError("Conversion failed")
mock_convert_md.side_effect = ConversionError("Conversion failed")
data = {
"title": "My Document",
@@ -357,8 +543,33 @@ def test_api_documents_create_for_owner_with_converter_exception(
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
mock_convert_md.assert_called_once_with("Document content")
mock_convert_markdown.assert_called_once_with("Document content")
assert response.status_code == 400
assert response.json() == {"content": ["Could not convert content"]}
assert response.status_code == 500
assert response.json() == {"detail": "could not convert content"}
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_empty_content():
"""The content should not be empty or a 400 error should be raised."""
data = {
"title": "My Document",
"content": " ",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert response.json() == {
"content": [
"This field may not be blank.",
],
}

View File

@@ -160,7 +160,7 @@ def test_api_documents_media_auth_authenticated_restricted():
@pytest.mark.parametrize("via", VIA)
def test_api_documents_media_auth_related(via, mock_user_teams):
"""
Users who have specific access to a document, whatever the role, should be able to
Users who have a specific access to a document, whatever the role, should be able to
retrieve related attachments.
"""
user = factories.UserFactory()

View File

@@ -647,7 +647,7 @@ def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
):
"""
Users who are administrators in a template should be allowed to delete access
Users who are administrators in a template should be allowed to delete an access
from the template provided it is not ownership.
"""
user = factories.UserFactory()

View File

@@ -20,6 +20,7 @@ pytestmark = pytest.mark.django_db
CRISP_WEBSITE_ID="123",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
SENTRY_DSN="https://sentry.test/123",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
@@ -41,5 +42,6 @@ def test_api_config(is_authenticated):
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
}

View File

@@ -84,7 +84,7 @@ def test_models_documents_file_key():
def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role):
"""
Check abilities returned for a document giving insufficient roles to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
@@ -121,7 +121,7 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
def test_models_documents_get_abilities_reader(is_authenticated, reach):
"""
Check abilities returned for a document giving reader role to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="reader")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
@@ -158,7 +158,7 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
def test_models_documents_get_abilities_editor(is_authenticated, reach):
"""
Check abilities returned for a document giving editor role to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
@@ -449,7 +449,7 @@ def test_models_documents__email_invitation__success():
def test_models_documents__email_invitation__success_fr():
"""
The email invitation is sent successfully in French.
The email invitation is sent successfully in french.
"""
document = factories.DocumentFactory()

View File

@@ -27,7 +27,7 @@ def test_models_users_id_unique():
def test_models_users_send_mail_main_existing():
"""The 'email_user' method should send mail to the user's email address."""
"""The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory()
with mock.patch("django.core.mail.send_mail") as mock_send:
@@ -37,7 +37,7 @@ def test_models_users_send_mail_main_existing():
def test_models_users_send_mail_main_missing():
"""The 'email_user' method should fail if the user has no email address."""
"""The "email_user' method should fail if the user has no email address."""
user = factories.UserFactory(email=None)
with pytest.raises(ValueError) as excinfo:

View File

@@ -1,5 +1,5 @@
"""
Test AI API endpoints in the impress core app.
Test ai API endpoints in the impress core app.
"""
import json

View File

@@ -0,0 +1,30 @@
"""
Unit tests for the User model
"""
import pytest
from impress.settings import Base
def test_invalid_settings_oidc_email_configuration():
"""
The OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and OIDC_ALLOW_DUPLICATE_EMAILS settings
should not be both set to True simultaneously.
"""
class TestSettings(Base):
"""Fake test settings."""
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
OIDC_ALLOW_DUPLICATE_EMAILS = True
# The validation is performed during post_setup
with pytest.raises(ValueError) as excinfo:
TestSettings().post_setup()
# Check the exception message
assert str(excinfo.value) == (
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)

View File

@@ -15,7 +15,7 @@ class Command(BaseCommand):
"""Define required arguments "email" and "password"."""
parser.add_argument(
"--email",
help="Email for the user.",
help=("Email for the user."),
)
parser.add_argument(
"--password",

View File

@@ -390,6 +390,11 @@ class Base(Configuration):
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
# Posthog
POSTHOG_KEY = values.DictValue(
None, environ_name="POSTHOG_KEY", environ_prefix=None
)
# Crisp
CRISP_WEBSITE_ID = values.Value(
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
@@ -474,6 +479,15 @@ class Base(Configuration):
environ_prefix=None,
)
# WARNING: Enabling this setting allows multiple user accounts to share the same email
# address. This may cause security issues and is not recommended for production use when
# email is activated as fallback for identification (see previous setting).
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
default=False,
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
environ_prefix=None,
)
USER_OIDC_ESSENTIAL_CLAIMS = values.ListValue(
default=[], environ_name="USER_OIDC_ESSENTIAL_CLAIMS", environ_prefix=None
)
@@ -622,9 +636,17 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
# Add the application name to the Sentry scope
scope = sentry_sdk.get_global_scope()
scope.set_tag("application", "backend")
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValueError(
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)
class Build(Base):

View File

@@ -1,9 +1,9 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
"PO-Revision-Date: 2025-01-16 19:53\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -11,384 +11,342 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:33
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Persönliche Daten"
#: core/admin.py:46
#: build/lib/core/admin.py:46 core/admin.py:46
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:58
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/api/filters.py:16
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
msgstr "Ersteller bin ich"
#: core/api/filters.py:19
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
msgstr "Favorit"
#: core/api/filters.py:22
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
msgid "Title"
msgstr ""
msgstr "Titel"
#: core/api/serializers.py:307
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
msgid "A new document was created on your behalf!"
msgstr ""
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: core/api/serializers.py:311
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
msgid "You have been granted ownership of a new document:"
msgstr ""
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: core/api/serializers.py:414
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
msgid "Body"
msgstr "Inhalt"
#: core/api/serializers.py:417
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
msgid "Body type"
msgstr "Typ"
#: core/api/serializers.py:423
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
msgid "Format"
msgstr "Format"
msgstr ""
#: core/authentication/backends.py:57
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:88
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
msgstr "Benutzerkonto ist deaktiviert"
#: core/models.py:62 core/models.py:69
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
#: core/models.py:70
msgid "Reader"
msgstr "Lesen"
#: core/models.py:63 core/models.py:70
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
#: core/models.py:71
msgid "Editor"
msgstr "Bearbeiten"
#: core/models.py:71
#: build/lib/core/models.py:72 core/models.py:72
msgid "Administrator"
msgstr "Administrator"
msgstr ""
#: core/models.py:72
#: build/lib/core/models.py:73 core/models.py:73
msgid "Owner"
msgstr "Besitzer"
#: core/models.py:83
#: build/lib/core/models.py:84 core/models.py:84
msgid "Restricted"
msgstr "Beschränkt"
#: core/models.py:87
#: build/lib/core/models.py:88 core/models.py:88
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:89
#: build/lib/core/models.py:90 core/models.py:90
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:101
#: build/lib/core/models.py:112 core/models.py:112
msgid "id"
msgstr ""
#: core/models.py:102
#: build/lib/core/models.py:113 core/models.py:113
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:108
#: build/lib/core/models.py:119 core/models.py:119
msgid "created on"
msgstr "Erstellt"
#: core/models.py:109
#: build/lib/core/models.py:120 core/models.py:120
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
#: core/models.py:114
#: build/lib/core/models.py:125 core/models.py:125
msgid "updated on"
msgstr "Aktualisiert"
#: core/models.py:115
#: build/lib/core/models.py:126 core/models.py:126
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: core/models.py:135
#: build/lib/core/models.py:162 core/models.py:162
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:175 core/models.py:175
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
msgstr "Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, Zahlen und die @/./+/-/_/: Zeichen enthalten."
#: core/models.py:141
#: build/lib/core/models.py:181 core/models.py:181
msgid "sub"
msgstr ""
msgstr "unter"
#: core/models.py:143
#: build/lib/core/models.py:183 core/models.py:183
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
msgstr "Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen @/./+/-/_/:"
#: core/models.py:152
#: build/lib/core/models.py:192 core/models.py:192
msgid "full name"
msgstr ""
msgstr "Name"
#: core/models.py:153
#: build/lib/core/models.py:193 core/models.py:193
msgid "short name"
msgstr ""
msgstr "Kurzbezeichnung"
#: core/models.py:155
#: build/lib/core/models.py:195 core/models.py:195
msgid "identity email address"
msgstr ""
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:160
#: build/lib/core/models.py:200 core/models.py:200
msgid "admin email address"
msgstr ""
msgstr "Admin E-Mail-Adresse"
#: core/models.py:167
#: build/lib/core/models.py:207 core/models.py:207
msgid "language"
msgstr "Sprache"
#: core/models.py:168
#: build/lib/core/models.py:208 core/models.py:208
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: core/models.py:174
#: build/lib/core/models.py:214 core/models.py:214
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: core/models.py:177
#: build/lib/core/models.py:217 core/models.py:217
msgid "device"
msgstr ""
msgstr "Gerät"
#: core/models.py:179
#: build/lib/core/models.py:219 core/models.py:219
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: core/models.py:182
#: build/lib/core/models.py:222 core/models.py:222
msgid "staff status"
msgstr ""
msgstr "Status des Teammitgliedes"
#: core/models.py:184
#: build/lib/core/models.py:224 core/models.py:224
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: core/models.py:187
#: build/lib/core/models.py:227 core/models.py:227
msgid "active"
msgstr ""
msgstr "aktiviert"
#: core/models.py:190
#: build/lib/core/models.py:230 core/models.py:230
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
#: core/models.py:202
#: build/lib/core/models.py:242 core/models.py:242
msgid "user"
msgstr "Benutzer"
#: core/models.py:203
#: build/lib/core/models.py:243 core/models.py:243
msgid "users"
msgstr "Benutzer"
#: core/models.py:342 core/models.py:718
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
#: core/models.py:758
msgid "title"
msgstr "Titel"
#: core/models.py:364
#: build/lib/core/models.py:404 core/models.py:404
msgid "Document"
msgstr "Dokument"
#: core/models.py:365
#: build/lib/core/models.py:405 core/models.py:405
msgid "Documents"
msgstr "Dokumente"
#: core/models.py:368
#: build/lib/core/models.py:408 core/models.py:408
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: core/models.py:593
#: build/lib/core/models.py:633 core/models.py:633
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: core/models.py:597
#: build/lib/core/models.py:637 core/models.py:637
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: core/models.py:600
#: build/lib/core/models.py:640 core/models.py:640
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: core/models.py:623
#: build/lib/core/models.py:663 core/models.py:663
msgid "Document/user link trace"
msgstr ""
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:624
#: build/lib/core/models.py:664 core/models.py:664
msgid "Document/user link traces"
msgstr ""
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:630
#: build/lib/core/models.py:670 core/models.py:670
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:653
#: build/lib/core/models.py:693 core/models.py:693
msgid "Document favorite"
msgstr ""
msgstr "Dokumentenfavorit"
#: core/models.py:654
#: build/lib/core/models.py:694 core/models.py:694
msgid "Document favorites"
msgstr ""
msgstr "Dokumentfavoriten"
#: core/models.py:660
#: build/lib/core/models.py:700 core/models.py:700
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: core/models.py:682
#: build/lib/core/models.py:722 core/models.py:722
msgid "Document/user relation"
msgstr ""
msgstr "Dokument/Benutzerbeziehung"
#: core/models.py:683
#: build/lib/core/models.py:723 core/models.py:723
msgid "Document/user relations"
msgstr ""
msgstr "Dokument/Benutzerbeziehungen"
#: core/models.py:689
#: build/lib/core/models.py:729 core/models.py:729
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: core/models.py:695
#: build/lib/core/models.py:735 core/models.py:735
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:701 core/models.py:890
#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
#: core/models.py:930
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: core/models.py:719
#: build/lib/core/models.py:759 core/models.py:759
msgid "description"
msgstr "Beschreibung"
#: core/models.py:720
#: build/lib/core/models.py:760 core/models.py:760
msgid "code"
msgstr "Code"
#: core/models.py:721
#: build/lib/core/models.py:761 core/models.py:761
msgid "css"
msgstr "CSS"
#: core/models.py:723
#: build/lib/core/models.py:763 core/models.py:763
msgid "public"
msgstr "öffentlich"
#: core/models.py:725
#: build/lib/core/models.py:765 core/models.py:765
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: core/models.py:731
#: build/lib/core/models.py:771 core/models.py:771
msgid "Template"
msgstr ""
msgstr "Vorlage"
#: core/models.py:732
#: build/lib/core/models.py:772 core/models.py:772
msgid "Templates"
msgstr ""
msgstr "Vorlagen"
#: core/models.py:871
#: build/lib/core/models.py:911 core/models.py:911
msgid "Template/user relation"
msgstr ""
msgstr "Vorlage/Benutzer-Beziehung"
#: core/models.py:872
#: build/lib/core/models.py:912 core/models.py:912
msgid "Template/user relations"
msgstr ""
msgstr "Vorlage/Benutzerbeziehungen"
#: core/models.py:878
#: build/lib/core/models.py:918 core/models.py:918
msgid "This user is already in this template."
msgstr ""
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: core/models.py:884
#: build/lib/core/models.py:924 core/models.py:924
msgid "This team is already in this template."
msgstr ""
msgstr "Dieses Team ist bereits in diesem Template."
#: core/models.py:907
#: build/lib/core/models.py:947 core/models.py:947
msgid "email address"
msgstr ""
msgstr "E-Mail-Adresse"
#: core/models.py:926
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document invitation"
msgstr ""
msgstr "Einladung zum Dokument"
#: core/models.py:927
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document invitations"
msgstr ""
msgstr "Dokumenteinladungen"
#: core/models.py:944
#: build/lib/core/models.py:987 core/models.py:987
msgid "This email is already associated to a registered user."
msgstr ""
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
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>"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:236
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
msgstr "Englisch"
#: impress/settings.py:237
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
msgstr "Französisch"
#: impress/settings.py:238
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""
msgstr "Deutsch"

View File

@@ -1,9 +1,9 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
"PO-Revision-Date: 2025-01-16 19:53\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -11,384 +11,342 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:33
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Infos Personnelles"
#: core/admin.py:46
#: build/lib/core/admin.py:46 core/admin.py:46
msgid "Permissions"
msgstr "Permissions"
msgstr ""
#: core/admin.py:58
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Dates importantes"
#: core/api/filters.py:16
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:19
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: core/api/filters.py:22
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
msgid "Title"
msgstr ""
#: core/api/serializers.py:307
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: core/api/serializers.py:311
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: core/api/serializers.py:414
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
msgid "Body"
msgstr ""
#: core/api/serializers.py:417
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
msgid "Body type"
msgstr ""
#: core/api/serializers.py:423
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
msgid "Format"
msgstr ""
#: core/authentication/backends.py:57
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:88
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: core/models.py:62 core/models.py:69
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
#: core/models.py:70
msgid "Reader"
msgstr "Lecteur"
#: core/models.py:63 core/models.py:70
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
#: core/models.py:71
msgid "Editor"
msgstr "Éditeur"
#: core/models.py:71
#: build/lib/core/models.py:72 core/models.py:72
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:72
#: build/lib/core/models.py:73 core/models.py:73
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:83
#: build/lib/core/models.py:84 core/models.py:84
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:87
#: build/lib/core/models.py:88 core/models.py:88
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:89
#: build/lib/core/models.py:90 core/models.py:90
msgid "Public"
msgstr "Public"
msgstr ""
#: core/models.py:101
#: build/lib/core/models.py:112 core/models.py:112
msgid "id"
msgstr ""
#: core/models.py:102
#: build/lib/core/models.py:113 core/models.py:113
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:108
#: build/lib/core/models.py:119 core/models.py:119
msgid "created on"
msgstr ""
#: core/models.py:109
#: build/lib/core/models.py:120 core/models.py:120
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:114
#: build/lib/core/models.py:125 core/models.py:125
msgid "updated on"
msgstr ""
#: core/models.py:115
#: build/lib/core/models.py:126 core/models.py:126
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:135
#: build/lib/core/models.py:162 core/models.py:162
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:175 core/models.py:175
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: core/models.py:141
#: build/lib/core/models.py:181 core/models.py:181
msgid "sub"
msgstr ""
#: core/models.py:143
#: build/lib/core/models.py:183 core/models.py:183
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: core/models.py:152
#: build/lib/core/models.py:192 core/models.py:192
msgid "full name"
msgstr ""
#: core/models.py:153
#: build/lib/core/models.py:193 core/models.py:193
msgid "short name"
msgstr ""
#: core/models.py:155
#: build/lib/core/models.py:195 core/models.py:195
msgid "identity email address"
msgstr ""
#: core/models.py:160
#: build/lib/core/models.py:200 core/models.py:200
msgid "admin email address"
msgstr ""
#: core/models.py:167
#: build/lib/core/models.py:207 core/models.py:207
msgid "language"
msgstr ""
#: core/models.py:168
#: build/lib/core/models.py:208 core/models.py:208
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:174
#: build/lib/core/models.py:214 core/models.py:214
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:177
#: build/lib/core/models.py:217 core/models.py:217
msgid "device"
msgstr ""
#: core/models.py:179
#: build/lib/core/models.py:219 core/models.py:219
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:182
#: build/lib/core/models.py:222 core/models.py:222
msgid "staff status"
msgstr ""
#: core/models.py:184
#: build/lib/core/models.py:224 core/models.py:224
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:187
#: build/lib/core/models.py:227 core/models.py:227
msgid "active"
msgstr ""
#: core/models.py:190
#: build/lib/core/models.py:230 core/models.py:230
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:202
#: build/lib/core/models.py:242 core/models.py:242
msgid "user"
msgstr ""
#: core/models.py:203
#: build/lib/core/models.py:243 core/models.py:243
msgid "users"
msgstr ""
#: core/models.py:342 core/models.py:718
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
#: core/models.py:758
msgid "title"
msgstr ""
#: core/models.py:364
#: build/lib/core/models.py:404 core/models.py:404
msgid "Document"
msgstr ""
#: core/models.py:365
#: build/lib/core/models.py:405 core/models.py:405
msgid "Documents"
msgstr ""
#: core/models.py:368
#: build/lib/core/models.py:408 core/models.py:408
msgid "Untitled Document"
msgstr ""
#: core/models.py:593
#: build/lib/core/models.py:633 core/models.py:633
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: core/models.py:597
#: build/lib/core/models.py:637 core/models.py:637
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant:"
#: core/models.py:600
#: build/lib/core/models.py:640 core/models.py:640
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous: {title}"
#: core/models.py:623
#: build/lib/core/models.py:663 core/models.py:663
msgid "Document/user link trace"
msgstr ""
#: core/models.py:624
#: build/lib/core/models.py:664 core/models.py:664
msgid "Document/user link traces"
msgstr ""
#: core/models.py:630
#: build/lib/core/models.py:670 core/models.py:670
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:653
#: build/lib/core/models.py:693 core/models.py:693
msgid "Document favorite"
msgstr ""
#: core/models.py:654
#: build/lib/core/models.py:694 core/models.py:694
msgid "Document favorites"
msgstr ""
#: core/models.py:660
#: build/lib/core/models.py:700 core/models.py:700
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: core/models.py:682
#: build/lib/core/models.py:722 core/models.py:722
msgid "Document/user relation"
msgstr ""
#: core/models.py:683
#: build/lib/core/models.py:723 core/models.py:723
msgid "Document/user relations"
msgstr ""
#: core/models.py:689
#: build/lib/core/models.py:729 core/models.py:729
msgid "This user is already in this document."
msgstr ""
#: core/models.py:695
#: build/lib/core/models.py:735 core/models.py:735
msgid "This team is already in this document."
msgstr ""
#: core/models.py:701 core/models.py:890
#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
#: core/models.py:930
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:719
#: build/lib/core/models.py:759 core/models.py:759
msgid "description"
msgstr ""
#: core/models.py:720
#: build/lib/core/models.py:760 core/models.py:760
msgid "code"
msgstr ""
#: core/models.py:721
#: build/lib/core/models.py:761 core/models.py:761
msgid "css"
msgstr ""
#: core/models.py:723
#: build/lib/core/models.py:763 core/models.py:763
msgid "public"
msgstr ""
#: core/models.py:725
#: build/lib/core/models.py:765 core/models.py:765
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:731
#: build/lib/core/models.py:771 core/models.py:771
msgid "Template"
msgstr ""
#: core/models.py:732
#: build/lib/core/models.py:772 core/models.py:772
msgid "Templates"
msgstr ""
#: core/models.py:871
#: build/lib/core/models.py:911 core/models.py:911
msgid "Template/user relation"
msgstr ""
#: core/models.py:872
#: build/lib/core/models.py:912 core/models.py:912
msgid "Template/user relations"
msgstr ""
#: core/models.py:878
#: build/lib/core/models.py:918 core/models.py:918
msgid "This user is already in this template."
msgstr ""
#: core/models.py:884
#: build/lib/core/models.py:924 core/models.py:924
msgid "This team is already in this template."
msgstr ""
#: core/models.py:907
#: build/lib/core/models.py:947 core/models.py:947
msgid "email address"
msgstr ""
#: core/models.py:926
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document invitation"
msgstr ""
#: core/models.py:927
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document invitations"
msgstr ""
#: core/models.py:944
#: build/lib/core/models.py:987 core/models.py:987
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
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>"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating 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:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Proposé par %(brandname)s "
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:236
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: impress/settings.py:237
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: impress/settings.py:238
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -0,0 +1,352 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
"PO-Revision-Date: 2025-01-16 19:53\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: nl\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:46 core/admin.py:46
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr ""
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
msgid "Title"
msgstr ""
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
msgid "Format"
msgstr ""
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
#: core/models.py:70
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
#: core/models.py:71
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:72 core/models.py:72
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:73 core/models.py:73
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:84 core/models.py:84
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:88 core/models.py:88
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:90 core/models.py:90
msgid "Public"
msgstr ""
#: build/lib/core/models.py:112 core/models.py:112
msgid "id"
msgstr ""
#: build/lib/core/models.py:113 core/models.py:113
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:119 core/models.py:119
msgid "created on"
msgstr ""
#: build/lib/core/models.py:120 core/models.py:120
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:125 core/models.py:125
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:126 core/models.py:126
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:162 core/models.py:162
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:175 core/models.py:175
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:181 core/models.py:181
msgid "sub"
msgstr ""
#: build/lib/core/models.py:183 core/models.py:183
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:192 core/models.py:192
msgid "full name"
msgstr ""
#: build/lib/core/models.py:193 core/models.py:193
msgid "short name"
msgstr ""
#: build/lib/core/models.py:195 core/models.py:195
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:200 core/models.py:200
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:207 core/models.py:207
msgid "language"
msgstr ""
#: build/lib/core/models.py:208 core/models.py:208
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:214 core/models.py:214
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:217 core/models.py:217
msgid "device"
msgstr ""
#: build/lib/core/models.py:219 core/models.py:219
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:222 core/models.py:222
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:224 core/models.py:224
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:227 core/models.py:227
msgid "active"
msgstr ""
#: build/lib/core/models.py:230 core/models.py:230
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:242 core/models.py:242
msgid "user"
msgstr ""
#: build/lib/core/models.py:243 core/models.py:243
msgid "users"
msgstr ""
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
#: core/models.py:758
msgid "title"
msgstr ""
#: build/lib/core/models.py:404 core/models.py:404
msgid "Document"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:408 core/models.py:408
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:633 core/models.py:633
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:637 core/models.py:637
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:640 core/models.py:640
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:663 core/models.py:663
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:664 core/models.py:664
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:670 core/models.py:670
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:694 core/models.py:694
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:700 core/models.py:700
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:722 core/models.py:722
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:723 core/models.py:723
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:729 core/models.py:729
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:735 core/models.py:735
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
#: core/models.py:930
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:759 core/models.py:759
msgid "description"
msgstr ""
#: build/lib/core/models.py:760 core/models.py:760
msgid "code"
msgstr ""
#: build/lib/core/models.py:761 core/models.py:761
msgid "css"
msgstr ""
#: build/lib/core/models.py:763 core/models.py:763
msgid "public"
msgstr ""
#: build/lib/core/models.py:765 core/models.py:765
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:771 core/models.py:771
msgid "Template"
msgstr ""
#: build/lib/core/models.py:772 core/models.py:772
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:911 core/models.py:911
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:912 core/models.py:912
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:918 core/models.py:918
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:924 core/models.py:924
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:947 core/models.py:947
msgid "email address"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:987 core/models.py:987
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.10.0"
version = "2.0.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -37,7 +37,7 @@ dependencies = [
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.4",
"django==5.1.5",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",

View File

@@ -16,6 +16,7 @@ const config = {
['de-de', 'German'],
],
LANGUAGE_CODE: 'en-us',
POSTHOG_KEY: {},
SENTRY_DSN: null,
};

View File

@@ -123,7 +123,7 @@ test.describe('Doc Editor', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
// When the visibility is changed, the ws should close the connection (backend signal)
// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();

View File

@@ -47,6 +47,7 @@ test.describe('Doc Header', () => {
versions_list: true,
versions_retrieve: true,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
@@ -394,7 +395,38 @@ test.describe('Doc Header', () => {
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
expect(clipboardContent.trim()).toBe(
`<h1 data-level=\"1\">Hello World</h1><p></p>`,
);
});
test('it checks the copy link button', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
accesses_manage: false,
accesses_view: false,
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
const shareButton = page.getByRole('button', {
name: 'Share',
exact: true,
});
await expect(shareButton).toBeVisible();
await shareButton.click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
});
});
@@ -405,6 +437,46 @@ test.describe('Documents Header mobile', () => {
await page.goto('/');
});
test('it checks the copy link button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
await mockedDocument(page, {
abilities: {
destroy: false,
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
accesses_manage: false,
accesses_view: false,
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
// Test that clipboard is in HTML format
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
const origin = await page.evaluate(() => window.location.origin);
expect(clipboardContent.trim()).toMatch(
`${origin}/docs/mocked-document-id/`,
);
});
test('it checks the close button on Share modal', async ({ page }) => {
await mockedDocument(page, {
abilities: {
@@ -414,6 +486,7 @@ test.describe('Documents Header mobile', () => {
versions_list: true,
versions_retrieve: true,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,

View File

@@ -160,7 +160,7 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Partager' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
name: 'Saisie de recherche rapide',
});
const email = randomName('test@test.fr', browserName, 1)[0];

View File

@@ -67,7 +67,7 @@ test.describe('Doc Visibility', () => {
test.describe('Doc Visibility: Restricted', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A doc is not accessible when not authenticated.', async ({
test('A doc is not accessible when not authentified.', async ({
page,
browserName,
}) => {
@@ -98,7 +98,7 @@ test.describe('Doc Visibility: Restricted', () => {
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('A doc is not accessible when authenticated but not member.', async ({
test('A doc is not accessible when authentified but not member.', async ({
page,
browserName,
}) => {
@@ -232,6 +232,9 @@ test.describe('Doc Visibility: Public', () => {
cardContainer.getByText('Public document', { exact: true }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'search' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
const urlDoc = page.url();
await page
@@ -245,7 +248,9 @@ test.describe('Doc Visibility: Public', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(page.getByRole('button', { name: 'search' })).toBeHidden();
await expect(page.getByRole('button', { name: 'New doc' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
@@ -309,14 +314,14 @@ test.describe('Doc Visibility: Public', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
test.describe('Doc Visibility: Authenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A doc is not accessible when unauthenticated.', async ({
test('A doc is not accessible when unauthentified.', async ({
page,
browserName,
}) => {
@@ -325,7 +330,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const [docTitle] = await createDoc(
page,
'Authenticated unauthenticated',
'Authenticated unauthentified',
browserName,
1,
);
@@ -409,13 +414,8 @@ test.describe('Doc Visibility: Authenticated', () => {
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(selectVisibility).toBeHidden();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await expect(inputSearch).toBeHidden();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
});
test('It checks a authenticated doc in editable mode', async ({
@@ -470,12 +470,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
await expect(selectVisibility).toBeHidden();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await expect(inputSearch).toBeHidden();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.10.0",
"version": "2.0.1",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -13,12 +13,12 @@
},
"devDependencies": {
"@playwright/test": "1.49.1",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
"typescript": "*",
"luxon": "3.5.0",
"@types/luxon": "3.4.2"
"typescript": "*"
},
"dependencies": {
"convert-stream": "1.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.10.0",
"version": "2.0.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -15,9 +15,9 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@blocknote/core": "0.22.0",
"@blocknote/mantine": "0.22.0",
"@blocknote/react": "0.22.0",
"@blocknote/core": "0.21.0",
"@blocknote/mantine": "0.21.0",
"@blocknote/react": "0.21.0",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.15.0",
"@openfun/cunningham-react": "2.9.4",
@@ -31,6 +31,7 @@
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.1.3",
"posthog-js": "1.204.0",
"react": "*",
"react-aria-components": "1.5.0",
"react-dom": "*",

View File

@@ -29,7 +29,7 @@ describe('fetchAPI', () => {
});
});
it('check the versioning', () => {
it('check the versionning', () => {
fetchMock.mock('http://test.jest/api/v2.0/some/url', 200);
void fetchAPI('some/url', {}, '2.0');

View File

@@ -20,12 +20,14 @@ export type DropdownMenuProps = {
showArrow?: boolean;
label?: string;
arrowCss?: BoxProps['$css'];
disabled?: boolean;
topMessage?: string;
};
export const DropdownMenu = ({
options,
children,
disabled = false,
showArrow = false,
arrowCss,
label,
@@ -40,6 +42,10 @@ export const DropdownMenu = ({
setIsOpen(isOpen);
};
if (disabled) {
return children;
}
return (
<DropButton
isOpen={isOpen}

View File

@@ -1,8 +1,8 @@
import Link from 'next/link';
import styled from 'styled-components';
import styled, { RuleSet } from 'styled-components';
export interface LinkProps {
$css?: string;
$css?: string | RuleSet<object>;
}
export const StyledLink = styled(Link)<LinkProps>`
@@ -12,5 +12,5 @@ export const StyledLink = styled(Link)<LinkProps>`
color: #ffffff;
}
display: flex;
${({ $css }) => $css && `${$css};`}
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
`;

View File

@@ -33,7 +33,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)));
}, [asPath]);
// We force to log in except on allowed paths
// We force to login except on allowed paths
useEffect(() => {
if (!initiated || authenticated || pathAllowed) {
return;

View File

@@ -46,8 +46,8 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
terminateCrispSession();
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 log in
// 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);

View File

@@ -3,7 +3,7 @@ import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { configureCrispSession } from '@/services';
import { PostHogProvider, configureCrispSession } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
import { useConfig } from './api/useConfig';
@@ -45,5 +45,5 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
);
}
return children;
return <PostHogProvider conf={conf.POSTHOG_KEY}>{children}</PostHogProvider>;
};

View File

@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
import { PostHogConf } from '@/services';
interface ConfigResponse {
LANGUAGES: [string, string][];
@@ -11,6 +12,7 @@ interface ConfigResponse {
CRISP_WEBSITE_ID?: string;
FRONTEND_THEME?: Theme;
MEDIA_BASE_URL?: string;
POSTHOG_KEY?: PostHogConf;
SENTRY_DSN?: string;
}

View File

@@ -576,6 +576,22 @@ input:-webkit-autofill:focus {
}
}
.c__modal__scroller:has(.noPadding) {
padding: 0 !important;
.c__modal__close .c__button {
right: 5px;
top: 5px;
padding: 1.5rem 1rem;
}
.c__modal__title {
font-size: var(--c--theme--font--sizes--xs);
padding: var(--c--theme--spacings--base);
margin-bottom: 0;
}
}
/**
* Toast
*/

View File

@@ -6,6 +6,7 @@ import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
@@ -20,17 +21,19 @@ import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
const cssEditor = (readonly: boolean) => `
&, & > .bn-container, & .ProseMirror {
height:100%;
.bn-side-menu[data-block-type=heading][data-level="1"] {
height: 50px;
}
.bn-side-menu[data-block-type=heading][data-level="2"] {
height: 43px;
}
.bn-side-menu[data-block-type=heading][data-level="3"] {
const cssEditor = (readonly: boolean) => css`
&,
& > .bn-container,
& .ProseMirror {
height: 100%;
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 50px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
}
h1 {
@@ -52,11 +55,11 @@ const cssEditor = (readonly: boolean) => `
border-left: none;
}
}
.bn-editor {
color: var(--c--theme--colors--greyscale-700);
}
.bn-block-outer:not(:first-child) {
&:has(h1) {
padding-top: 32px;
@@ -67,25 +70,25 @@ const cssEditor = (readonly: boolean) => `
&:has(h3) {
padding-top: 16px;
}
};
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
border-radius: 4px;
}
@media screen and (width <= 560px) {
& .bn-editor {
${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"] {
.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"] {
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 40px;
}
& .bn-editor h1 {
@@ -97,7 +100,7 @@ const cssEditor = (readonly: boolean) => `
& .bn-editor h3 {
font-size: 1.2rem;
}
.bn-block-content[data-is-empty-and-focused][data-content-type="paragraph"]
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
@@ -176,7 +179,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}, [setEditor, editor]);
return (
<Box $css={cssEditor(readOnly)}>
<Box
$padding={{ top: 'md' }}
$background="white"
$css={cssEditor(readOnly)}
>
{errorAttachment && (
<Box $margin={{ bottom: 'big' }}>
<TextErrors

View File

@@ -10,7 +10,7 @@ import { toBase64 } from '../utils';
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
});
const [initialDoc, setInitialDoc] = useState<string>(
toBase64(Y.encodeStateAsUpdate(doc)),

View File

@@ -42,7 +42,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
<Box
aria-label={t('Public document')}
$color={colors['primary-800']}
$background={colors['primary-100']}
$background={colors['primary-050']}
$radius={spacings['3xs']}
$direction="row"
$padding="xs"
@@ -64,7 +64,12 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
</Text>
</Box>
)}
<Box $direction="row" $align="center" $width="100%">
<Box
$direction="row"
$align="center"
$width="100%"
$padding={{ bottom: 'xs' }}
>
<Box
$direction="row"
$justify="space-between"
@@ -98,7 +103,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
<DocToolBox doc={doc} />
</Box>
</Box>
<HorizontalSeparator $withPadding={true} />
<HorizontalSeparator $withPadding={false} />
</Box>
</>
);

View File

@@ -62,7 +62,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_DOC, KEY_LIST_DOC],
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess(data) {
if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);

View File

@@ -16,7 +16,6 @@ import {
Icon,
IconOptions,
} from '@/components';
import { useAuthStore } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/features/docs/doc-editor/';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
@@ -37,6 +36,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const hasAccesses = doc.nb_accesses > 1;
const queryClient = useQueryClient();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
@@ -48,7 +48,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const modalShare = useModal();
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { authenticated } = useAuthStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
@@ -57,10 +56,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
? [
{
label: t('Share'),
icon: 'upload',
callback: () => {
modalShare.open();
},
icon: 'group',
callback: modalShare.open,
},
{
label: t('Export'),
@@ -153,7 +150,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
$margin={{ left: 'auto' }}
$gap={spacings['2xs']}
>
{authenticated && !isSmallMobile && (
{!isSmallMobile && (
<>
{!hasAccesses && (
<Button
@@ -193,6 +190,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
)}
</>
)}
{!isSmallMobile && (
<Button
color="tertiary-text"

View File

@@ -94,7 +94,7 @@ const convertToImg = (html: string) => {
const doc = parser.parseFromString(html, 'text/html');
const divs = doc.querySelectorAll('div[data-content-type="image"]');
// Loop through each div and replace it with an img
// Loop through each div and replace it with a img
divs.forEach((div) => {
const img = document.createElement('img');

View File

@@ -20,18 +20,18 @@ export const createFavoriteDoc = async ({ id }: CreateFavoriteDocParams) => {
interface CreateFavoriteDocProps {
onSuccess?: () => void;
listInvalidQueries?: string[];
listInvalideQueries?: string[];
}
export function useCreateFavoriteDoc({
onSuccess,
listInvalidQueries,
listInvalideQueries,
}: CreateFavoriteDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, CreateFavoriteDocParams>({
mutationFn: createFavoriteDoc,
onSuccess: () => {
listInvalidQueries?.forEach((queryKey) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -20,18 +20,18 @@ export const deleteFavoriteDoc = async ({ id }: DeleteFavoriteDocParams) => {
interface DeleteFavoriteDocProps {
onSuccess?: () => void;
listInvalidQueries?: string[];
listInvalideQueries?: string[];
}
export function useDeleteFavoriteDoc({
onSuccess,
listInvalidQueries,
listInvalideQueries,
}: DeleteFavoriteDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteFavoriteDocParams>({
mutationFn: deleteFavoriteDoc,
onSuccess: () => {
listInvalidQueries?.forEach((queryKey) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -26,18 +26,18 @@ export const updateDoc = async ({
interface UpdateDocProps {
onSuccess?: (data: Doc) => void;
listInvalidQueries?: string[];
listInvalideQueries?: string[];
}
export function useUpdateDoc({
onSuccess,
listInvalidQueries,
listInvalideQueries,
}: UpdateDocProps = {}) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError, UpdateDocParams>({
mutationFn: updateDoc,
onSuccess: (data) => {
listInvalidQueries?.forEach((queryKey) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -30,12 +30,12 @@ export const updateDocLink = async ({
interface UpdateDocLinkProps {
onSuccess?: (data: Doc) => void;
listInvalidQueries?: string[];
listInvalideQueries?: string[];
}
export function useUpdateDocLink({
onSuccess,
listInvalidQueries,
listInvalideQueries,
}: UpdateDocLinkProps = {}) {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
@@ -43,7 +43,7 @@ export function useUpdateDocLink({
return useMutation<Doc, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
onSuccess: (data, variable) => {
listInvalidQueries?.forEach((queryKey) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -1,2 +1,3 @@
export * from './useCollaboration';
export * from './useTrans';
export * from './useCopyDocLink';

View File

@@ -0,0 +1,19 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useClipboard } from '@/hook';
import { Doc } from '../types';
export const useCopyDocLink = (docId: Doc['id']) => {
const { t } = useTranslation();
const copyToClipboard = useClipboard();
return useCallback(() => {
copyToClipboard(
`${window.location.origin}/docs/${docId}/`,
t('Link Copied !'),
t('Failed to copy link'),
);
}, [copyToClipboard, docId, t]);
};

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce';
import { Box, LoadMoreText } from '@/components';
import { Box, HorizontalSeparator, LoadMoreText, Text } from '@/components';
import {
QuickSearch,
QuickSearchData,
@@ -58,6 +58,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
const [listHeight, setListHeight] = useState<string>('400px');
const canShare = doc.abilities.accesses_manage;
const canViewAccesses = doc.abilities.accesses_view;
const showMemberSection = inputValue === '' && selectedUsers.length === 0;
const showFooter = selectedUsers.length === 0 && !inputValue;
@@ -137,7 +138,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
};
return {
groupName: t('Search user result', { count: users.length }),
groupName: t('Search user result'),
elements: users,
endActions:
isEmail && users.length === 0
@@ -191,8 +192,9 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
<ShareModalStyle />
<Box
aria-label={t('Share modal')}
$height={modalContentHeight}
$height={canViewAccesses ? modalContentHeight : 'auto'}
$overflow="hidden"
className="noPadding"
$justify="space-between"
>
<Box
@@ -204,7 +206,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
}
`}
>
<div ref={selectedUsersRef}>
<Box ref={selectedUsersRef}>
{canShare && selectedUsers.length > 0 && (
<Box
$padding={{ horizontal: 'base' }}
@@ -222,55 +224,76 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
/>
</Box>
)}
</div>
{!canViewAccesses && <HorizontalSeparator />}
</Box>
<Box data-testid="doc-share-quick-search">
<QuickSearch
onFilter={(str) => {
setInputValue(str);
onFilter(str);
}}
inputValue={inputValue}
showInput={canShare}
loading={searchUsersQuery.isLoading}
placeholder={t('Type a name or email')}
>
{!showMemberSection && inputValue !== '' && (
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => (
<DocShareModalInviteUserRow user={user} />
{!canViewAccesses && (
<Box $height={listHeight} $align="center" $justify="center">
<Text
$maxWidth="320px"
$textAlign="center"
$variation="600"
$size="sm"
>
{t(
'You do not have permission to view users sharing this document or modify link settings.',
)}
/>
)}
{showMemberSection && (
<>
{invitationsData.elements.length > 0 && (
<Box aria-label={t('List invitation card')}>
</Text>
</Box>
)}
{canViewAccesses && (
<QuickSearch
onFilter={(str) => {
setInputValue(str);
onFilter(str);
}}
inputValue={inputValue}
showInput={canShare}
loading={searchUsersQuery.isLoading}
placeholder={t('Type a name or email')}
>
{canViewAccesses && (
<>
{!showMemberSection && inputValue !== '' && (
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem
doc={doc}
invitation={invitation}
/>
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => (
<DocShareModalInviteUserRow user={user} />
)}
/>
</Box>
)}
)}
{showMemberSection && (
<>
{invitationsData.elements.length > 0 && (
<Box aria-label={t('List invitation card')}>
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem
doc={doc}
invitation={invitation}
/>
)}
/>
</Box>
)}
<Box aria-label={t('List members card')}>
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</Box>
</>
)}
</QuickSearch>
<Box aria-label={t('List members card')}>
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</Box>
</>
)}
</>
)}
</QuickSearch>
)}
</Box>
</Box>

View File

@@ -1,13 +1,9 @@
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, HorizontalSeparator } from '@/components';
import { Doc } from '@/features/docs';
import { Doc, useCopyDocLink } from '@/features/docs';
import { DocVisibility } from './DocVisibility';
@@ -17,8 +13,7 @@ type Props = {
};
export const DocShareModalFooter = ({ doc, onClose }: Props) => {
const canShare = doc.abilities.accesses_manage;
const { toast } = useToastProvider();
const copyDocLink = useCopyDocLink(doc.id);
const { t } = useTranslation();
return (
<Box
@@ -27,12 +22,10 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
`}
>
<HorizontalSeparator $withPadding={true} />
{canShare && (
<>
<DocVisibility doc={doc} />
<HorizontalSeparator />
</>
)}
<DocVisibility doc={doc} />
<HorizontalSeparator />
<Box
$direction="row"
$justify="space-between"
@@ -41,18 +34,7 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
<Button
fullWidth={false}
onClick={() => {
navigator.clipboard
.writeText(window.location.href)
.then(() => {
toast(t('Link Copied !'), VariantType.SUCCESS, {
duration: 3000,
});
})
.catch(() => {
toast(t('Failed to copy link'), VariantType.ERROR, {
duration: 3000,
});
});
copyDocLink();
}}
color="tertiary"
icon={<span className="material-icons">add_link</span>}

View File

@@ -26,10 +26,10 @@ export const DocShareModalInviteUserRow = ({ user }: Props) => {
color: var(--c--theme--colors--greyscale-400);
`}
>
<Text $theme="primary" $variation="600">
<Text $theme="primary" $variation="800">
{t('Add')}
</Text>
<Icon $theme="primary" $variation="600" iconName="add" />
<Icon $theme="primary" $variation="800" iconName="add" />
</Box>
}
/>

View File

@@ -34,6 +34,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const spacing = spacingsTokens();
const colors = colorsTokens();
const canManage = doc.abilities.accesses_manage;
const [linkReach, setLinkReach] = useState<LinkReach>(doc.link_reach);
const [docLinkRole, setDocLinkRole] = useState<LinkRole>(doc.link_role);
const { linkModeTranslations, linkReachChoices, linkReachTranslations } =
@@ -49,7 +50,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
},
);
},
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const updateReach = (link_reach: LinkReach) => {
@@ -106,29 +107,35 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
$direction="row"
$align={isDesktop ? 'center' : undefined}
$padding={{ horizontal: '2xs' }}
$gap={spacing['3xs']}
$gap={canManage ? spacing['3xs'] : spacing['base']}
>
<DropdownMenu
label={t('Visibility')}
arrowCss={css`
color: ${colors['primary-800']} !important;
`}
disabled={!canManage}
showArrow={true}
options={linkReachOptions}
>
<Box $direction="row" $align="center" $gap={spacing['3xs']}>
<Icon
$theme="primary"
$variation="800"
$theme={canManage ? 'primary' : 'greyscale'}
$variation={canManage ? '800' : '600'}
iconName={linkReachChoices[linkReach].icon}
/>
<Text $theme="primary" $variation="800">
<Text
$theme={canManage ? 'primary' : 'greyscale'}
$variation={canManage ? '800' : '600'}
$weight="500"
$size="md"
>
{linkReachChoices[linkReach].label}
</Text>
</Box>
</DropdownMenu>
{isDesktop && (
<Text $size="xs" $variation="600">
<Text $size="xs" $variation="600" $weight="400">
{description}
</Text>
)}
@@ -137,6 +144,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
<Box $direction="row" $align="center" $gap={spacing['3xs']}>
{linkReach !== LinkReach.RESTRICTED && (
<DropdownMenu
disabled={!canManage}
showArrow={true}
options={linkMode}
label={t('Visibility mode')}

View File

@@ -26,13 +26,13 @@ export const useTranslatedShareSettings = () => {
},
[LinkReach.AUTHENTICATED]: {
label: linkReachTranslations[LinkReach.AUTHENTICATED],
icon: 'corporate_fare',
icon: 'vpn_lock',
value: LinkReach.AUTHENTICATED,
descriptionReadOnly: t(
'Anyone with the link can see the document provided they are logged in',
'Anyone with the link can view the document if they are logged in',
),
descriptionEdit: t(
'Anyone with the link can edit provided they are logged in',
'Anyone with the link can edit the document if they are logged in',
),
},
[LinkReach.PUBLIC]: {

View File

@@ -8,7 +8,7 @@ import { useResponsiveStore } from '@/stores';
const leftPaddingMap: { [key: number]: string } = {
3: '1.5rem',
2: '0.9rem',
1: '0.3',
1: '0.3rem',
};
export type HeadingsHighlight = {
@@ -44,7 +44,7 @@ export const Heading = ({
onMouseOver={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
onClick={() => {
// With mobile the focus open the keyboard and the scroll are not working
// With mobile the focus open the keyboard and the scroll is not working
if (!isMobile) {
editor.focus();
}

View File

@@ -42,7 +42,7 @@ export const ModalConfirmationVersion = ({
const { push } = useRouter();
const { provider } = useProviderStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
const onDisplaySuccess = () => {
toast(t('Version restored successfully'), VariantType.SUCCESS);

View File

@@ -10,6 +10,8 @@ import {
} from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
import { DocsGridItem } from './DocsGridItem';
import { DocsGridLoader } from './DocsGridLoader';
@@ -22,6 +24,7 @@ export const DocsGrid = ({
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
const { flexLeft, flexRight } = useResponsiveDocGrid();
const {
data,
@@ -89,7 +92,7 @@ export const DocsGrid = ({
{title}
</Text>
{!hasDocs && (
{!hasDocs && !loading && (
<Box $padding={{ vertical: 'sm' }} $align="center" $justify="center">
<Text $size="sm" $variation="600" $weight="700">
{t('No documents found')}
@@ -101,23 +104,21 @@ export const DocsGrid = ({
<Box
$direction="row"
$padding={{ horizontal: 'xs' }}
$gap="20px"
$gap="10px"
data-testid="docs-grid-header"
>
<Box $flex={6} $padding="3xs">
<Box $flex={flexLeft} $padding="3xs">
<Text $size="xs" $variation="600" $weight="500">
{t('Name')}
</Text>
</Box>
{isDesktop && (
<Box $flex={2} $padding="3xs">
<Box $flex={flexRight} $padding={{ vertical: '3xs' }}>
<Text $size="xs" $weight="500" $variation="600">
{t('Updated at')}
</Text>
</Box>
)}
<Box $flex={1.15} $align="flex-end" $padding="3xs" />
</Box>
{/* Body */}

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