Compare commits

..

34 Commits

Author SHA1 Message Date
Anthony LC
8dcba65ddf test 2024-11-14 13:13:07 +01:00
Jacques ROUSSEL
7e8667f80f 🔐(frontend) bump secret
Add sentry secret for the frontend
2024-11-13 15:05:49 +01:00
Anthony LC
8f73b78476 🩹(backend) get current release from pyproject.toml
In Sentry it is nice to have the release version,
it will help us to track the errors.
get_release was returning NA, we fixed it by
getting the version from pyproject.toml.
To do so we use tomllib, native library since
Python 3.11.
Our docker image is using Python 3.12.6, but our
CI was using Python 3.10, so we bump the CI python
version as well to be able to use tomllib.
2024-11-13 15:05:49 +01:00
Anthony LC
60a66e8783 🧐(backend) adapt Sentry config
We add SENTRY_ENV var, to be able to distinguish
errors between different environments in Sentry
(staging / preprod / prod).
2024-11-13 13:26:09 +01:00
Anthony LC
14a1642fb8 🧐(frontend) add sentry on the frontend side
We want to monitor the frontend side with sentry.
This commit adds the necessary configuration
to the frontend side to send errors to sentry.
2024-11-13 13:26:09 +01:00
Anthony LC
5ee6a43f08 (frontend) add useBroadcastStore
Add the useBroadcastStore.
It will give us the ability to easily
broadcast actions to all connected clients.

In this case, we requery the doc to everyone
when a change relative to the doc rights is made.
2024-11-09 10:21:24 +01:00
Anthony LC
8bd83cbfcd 🚚(frontend) move useDocStore to doc-management
We want to make more accessible the doc store
to every feature, so we move it to the
doc-management folder.
2024-11-09 10:21:24 +01:00
Anthony LC
bc14d1d0f8 🐛(editor) collaborative user tag hidden when read only
When the user was in read-only mode, the user
tag could be displayed when they were touching the
doc. This commit fixes this issue.
We add the full name instead of the email in the
cursor tag.
2024-11-08 12:01:23 +01:00
Anthony LC
526e649f06 🦺(backend) add comma to sub regex
Some sub have comma, the regex was a bit too strict
and didn't allow it, this commit fixes that.
2024-11-08 10:53:53 +01:00
Anthony LC
ac40eb8f7c 🌐(frontend) add German translation
- Add the german translation to Docs
- Add the german language to the frontend
language picker
2024-11-07 15:58:49 +01:00
lindenb1
c750cf10a8 🌐(backend) adding de_DE translation for the backend
This adds German translation to the backend and
adjusts the .po file sequence by priority.

Signed-off-by: lindenb1 <linden@b1-systems.de>
2024-11-07 11:49:41 +01:00
Samuel Paccoud - DINUM
4f4951cdcd 🚸(backend) improve users similarity search and sort results
In some edge cases, the domain part the email addresse is
longer than the name part. Users searches by email similarity
then return a lot of unsorted results.

We can improve this by being more demanding on similarity when
the query looks like an email. Sorting results by the similarity
score is also an obvious improvement.

At the moment, we still think it is good to propose results with
a weak similarity on the name part because we want to avoid
as much as possible creating duplicate users by inviting one of
is many emails, a user who is already in our database.

Fixes 399
2024-11-06 08:27:18 +01:00
Anthony LC
50891afd05 🔖(minor) release 1.7.0
Added:
- 📝Contributing.md
- 🌐(frontend) add localization to editor
- Public and restricted doc editable
- (frontend) Add full name if available
- (backend) Add view accesses ability

Changed:
- ♻️(frontend) avoid documents indexing in search engine
- ♻️(frontend) list accesses if user has abilities
- 👔(backend) doc restricted by default

Fixed:
- 🐛(backend) require right to manage document
  accesses to see invitations
- 🐛(i18n) same frontend and backend language using
  shared cookies
- 🐛(frontend) add default toolbar buttons
- 🐛(frontend) throttle error correctly display

Removed:
- 🔥(helm) remove infra related codes
2024-10-25 14:41:48 +02:00
Anthony LC
cbb6fc740a 👔(backend) doc restricted by default
By default a created document was in "authenticated"
mode, we switch to "restricted" by default.
2024-10-25 14:25:48 +02:00
Anthony LC
31c3dd6119 🛂(frontend) show member list depend ability
We integrate the new ability "accesses_view" that
tells if a user can view the accesses of a document.
2024-10-24 17:31:34 +02:00
Samuel Paccoud - DINUM
15700ddd8d (backend) add new ability on document "accesses_view"
We need this ability in the frontend to know whether we should try
to display the list of users who have document accesses. If this
ability is False (e.g for anonymous users), we should only show
the link reach and link role when clicking on the "Share" button.
2024-10-24 17:31:34 +02:00
Anthony LC
d8673a8cf7 (frontend) display full name if available
We can get the full name from the OIDC, so we should
display it if available.
2024-10-24 10:52:58 +02:00
NathanPanchout
a5af9f0776 🐛(frontend) avoid documents indexing in search engine
Some documents are available publicly (without being logged) and may thus end-up
being indexed by search engine.
2024-10-24 10:43:13 +02:00
Anthony LC
d715e7b3b6 🌐(frontend) translate last features
Translate:
- Mardown Buttons
- doc public editable
2024-10-24 10:15:28 +02:00
Jacques ROUSSEL
1da5a6a411 🗑️(ci) clean old deployment and ci
We move deployment stuff to a new repository. we don't need this
codeanymore
2024-10-24 09:50:18 +02:00
Anthony LC
af5ffc22ac (e2e) fix flaky tests
Fix a flaky tests on the e2e test:
- "it renders correctly when we switch from one doc
to another"
- "it saves the doc when we change pages"
2024-10-23 18:11:08 +02:00
Anthony LC
3434029654 ♻️(frontend) improve handleAIError
To display the throttle error messages,
we are doing a condition on the error message
that we get from the backend.
It is error prone because the backend error
message are internationalized.
This commit fixes this issue.
It DRY the component as well.
2024-10-23 18:11:08 +02:00
virgile-deville
6baa06bd3f 📝(Documentation) add an issue selection section
Added a link to the github project so that contributors know what to prioritize.
2024-10-23 17:08:48 +02:00
Anthony LC
8107d4f531 📝(contributing) add changelog part in contributing
We add a new section in the CONTRIBUTING.md file
to explain how to update the CHANGELOG.md file.
We improve the pull request section as well.
2024-10-23 12:46:49 +02:00
Anthony LC
f8c8044605 🧑‍💻(makefile) add frontend-lint cmd
Add the command frontend-lint to the makefile.
2024-10-23 12:46:49 +02:00
rvveber
a84f4de02c 🔨(i18n) disable key separation for translations
Improves on commit bfde526
2024-10-23 12:35:48 +02:00
rvveber
3c374e3cc7 🐛(i18n) same frontend and backend language using shared cookies
frontend: switch to cookie-based language selection
backend: use cookie for language
2024-10-23 12:35:48 +02:00
Anthony LC
ff364f8b3d (frontend) increase doc visibility options
We now have 3 visibility options for docs:
- public
- restricted
- authenticated

We also have 2 editability options:
- readonly
- editable

The editability options are only available
for public and authenticated docs.
2024-10-23 11:20:33 +02:00
Anthony LC
c0cb12f002 ♻️(frontend) minor components update
- change flex property of Box component
- Forward the ref of Text component
- globalize tooltip padding
2024-10-23 11:20:33 +02:00
Samuel Paccoud - DINUM
0f0f812059 🐛(backend) fix invitations API endpoint access rights
Only users who have the rights to manage accesses on the document should
be allowed to see and manipulate invitations. Other users can see access
rights on the document but only when the corresponding user/team has
actually been granted access.

We added a parameter in document abilities so the frontend knows when
the logged-in user can invite another user with the owner role or not.
2024-10-22 19:39:59 +02:00
NathanPanchout
7fc59ed497 🌐(frontend) add localization to editor
Currently, when you change language the editor does not change. So we add this
functionality
2024-10-22 13:54:20 +02:00
renovate[bot]
60120852f5 ⬆️(dependencies) update js dependencies 2024-10-21 09:55:17 +02:00
Anthony LC
f2c389e2b3 🐛(frontend) add default toolbar buttons
We are overriding the default toolbar to add the
markdown and ai buttons. By doing that we were
missing some default buttons that are useful depend
on the block type. This commit adds the default
buttons to the toolbar.
2024-10-21 09:45:47 +02:00
renovate[bot]
305359ae15 ⬆️(dependencies) update python dependencies 2024-10-21 09:20:33 +02:00
138 changed files with 3806 additions and 9535 deletions

View File

@@ -1,52 +0,0 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"

View File

@@ -6,14 +6,13 @@ on:
push:
branches:
- 'main'
- 'feature/add-sentry'
tags:
- 'v*'
pull_request:
branches:
- 'main'
env:
DOCKER_USER: 1001:127
SENTRY_DSN: ""
jobs:
build-and-push-backend:
@@ -112,7 +111,10 @@ jobs:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
SENTRY_DSN=${{ env.SENTRY_DSN }}
SENTRY_ENV=${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && 'production' || 'staging' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "impress,secrets"

View File

@@ -107,7 +107,9 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -199,7 +201,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12.6"
- name: Install development dependencies
run: pip install --user .[dev]

View File

@@ -11,7 +11,48 @@ and this project adheres to
## Added
- 🌐(frontend) Add German translation #255
- ✨(frontend) Add a broadcast store #387
- 🔊(project) Add sentry #410
## Changed
- 🚸(backend) improve users similarity search and sort results #391
- 🌐(backend) add german translation #259
## Fixed
- 🦺(backend) add comma to sub regex #408
- 🐛(editor) collaborative user tag hidden when read only #385
- 🐛(frontend) user have view access when revoked #387
## [1.7.0] - 2024-10-24
## Added
- 📝Contributing.md #352
- 🌐(frontend) add localization to editor #368
- ✨Public and restricted doc editable #357
- ✨(frontend) Add full name if available #380
- ✨(backend) Add view accesses ability #376
## Changed
- ♻️(frontend) list accesses if user has abilities #376
- ♻️(frontend) avoid documents indexing in search engine #372
- 👔(backend) doc restricted by default #388
## Fixed
- 🐛(backend) require right to manage document accesses to see invitations #369
- 🐛(i18n) same frontend and backend language using shared cookies #365
- 🐛(frontend) add default toolbar buttons #355
- 🐛(frontend) throttle error correctly display #378
## Removed
- 🔥(helm) remove infra related codes #366
## [1.6.0] - 2024-10-17
@@ -20,11 +61,11 @@ and this project adheres to
- ✨AI to doc editor #250
- ✨(backend) allow uploading more types of attachments #309
- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #300
- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #318
## Changed
- ♻️(frontend) More multi theme friendly #325
- ♻️(frontend) more multi theme friendly #325
- ♻️ Bootstrap frontend #257
- ♻️ Add username in email #314
@@ -215,7 +256,8 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.6.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.7.0...main
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0

View File

@@ -14,13 +14,19 @@ When creating an issue, please provide the following details:
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
## Selecting an issue
We use a [GitHub Project](https://github.com/orgs/numerique-gouv/projects/13) in order to prioritize our workload.
Please check in priority the issues that are in the **todo** column and have a higher priority (P0 -> P2).
## Commit Message Format
All commit messages must adhere to the following format:
`<gitmoji>(type) title description`
* **<gitmoji>**: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
* **title**: A short, descriptive title for the change, starting with a lowercase character.
* **description**: Include additional details about what was changed and why.
@@ -33,9 +39,28 @@ All commit messages must adhere to the following format:
Implemented login and signup features, and integrated OAuth2 for social login.
```
## Changelog Update
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
### Example Changelog Message
```
## [Unreleased]
## Added
- ✨(frontend) add AI to the project #321
```
## Pull Requests
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes.
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
### Don't forget to:
- check your commits
- check the linting: `make lint && make frontend-lint`
- check the tests: `make test`
- add a changelog entry
Once all the required tests have passed, you can request a review from the project maintainers.

View File

@@ -314,6 +314,10 @@ frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-install
frontend-lint: ## run the frontend linter
cd $(PATH_FRONT) && yarn lint
.PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev

View File

@@ -39,18 +39,6 @@ docker_build(
]
)
docker_build(
'localhost:5001/impress-blocknote:latest',
context='..',
dockerfile='../src/blocknote/Dockerfile',
only=['./src/blocknote', './docker', './.dockerignore'],
target = 'production',
live_update=[
sync('../src/blocknote', '/home/blocknote'),
]
)
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
migration = '''

View File

@@ -15,7 +15,7 @@ services:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
# user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=impress
@@ -134,16 +134,6 @@ services:
ports:
- "3000:3000"
blocknote-converter:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/blocknote/Dockerfile
target: production
image: blocknote:blocknote-production
ports:
- "8081:8081"
dockerize:
image: jwilder/dockerize

View File

@@ -44,3 +44,6 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Sentry
SENTRY_ENV=development

Submodule secrets updated: 38594182e8...d91797b97f

View File

@@ -1,9 +1,12 @@
"""Permission handlers for the impress core app."""
from django.core import exceptions
from django.db.models import Q
from rest_framework import permissions
from core.models import DocumentAccess, RoleChoices
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
}
@@ -59,6 +62,38 @@ class IsOwnedOrPublic(IsAuthenticated):
return False
class CanCreateInvitationPermission(permissions.BasePermission):
"""
Custom permission class to handle permission checks for managing invitations.
"""
def has_permission(self, request, view):
user = request.user
# Ensure the user is authenticated
if not (bool(request.auth) or request.user.is_authenticated):
return False
# Apply permission checks only for creation (POST requests)
if view.action != "create":
return True
# Check if resource_id is passed in the context
try:
document_id = view.kwargs["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a document ID in kwargs to manage document invitations."
) from exc
# Check if the user has access to manage invitations (Owner/Admin roles)
return DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN],
).exists()
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""

View File

@@ -328,48 +328,36 @@ class InvitationSerializer(serializers.ModelSerializer):
return {}
def validate(self, attrs):
"""Validate and restrict invitation to new user based on email."""
"""Validate invitation data."""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
try:
document_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a document ID in kwargs to create a new document invitation."
) from exc
attrs["document_id"] = self.context["resource_id"]
if not user and user.is_authenticated:
raise exceptions.PermissionDenied(
"Anonymous users are not allowed to create invitations."
)
# Only set the issuer if the instance is being created
if self.instance is None:
attrs["issuer"] = user
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage invitations for this document."
)
return attrs
if (
role == models.RoleChoices.OWNER
and not models.DocumentAccess.objects.filter(
def validate_role(self, role):
"""Custom validation for the role field."""
request = self.context.get("request")
user = getattr(request, "user", None)
document_id = self.context["resource_id"]
# If the role is OWNER, check if the user has OWNER access
if role == models.RoleChoices.OWNER:
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role=models.RoleChoices.OWNER,
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a document can invite other users as owners."
)
).exists():
raise serializers.ValidationError(
"Only owners of a document can invite other users as owners."
)
attrs["document_id"] = document_id
attrs["issuer"] = user
return attrs
return role
class VersionFilterSerializer(serializers.Serializer):

View File

@@ -6,6 +6,7 @@ from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db.models import (
@@ -156,8 +157,21 @@ class UserViewSet(
# Filter users by email similarity
if query := self.request.GET.get("q", ""):
# For performance reasons we filter first by similarity, which relies on an index,
# then only calculate precise similarity scores for sorting purposes
queryset = queryset.filter(email__trigram_word_similar=query)
queryset = queryset.annotate(
similarity=TrigramSimilarity("email", query)
)
# When the query only is on the name part, we should try to make many proposals
# But when the query looks like an email we should only propose serious matches
threshold = 0.6 if "@" in query else 0.1
queryset = queryset.filter(similarity__gt=threshold).order_by(
"-similarity"
)
return queryset
@decorators.action(
@@ -807,7 +821,10 @@ class InvitationViewset(
lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
permission_classes = [
permissions.CanCreateInvitationPermission,
permissions.AccessPermission,
]
queryset = (
models.Invitation.objects.all()
.select_related("document")
@@ -842,10 +859,16 @@ class InvitationViewset(
)
queryset = (
# The logged-in user should be part of a document to see its accesses
# The logged-in user should be administrator or owner to see its accesses
queryset.filter(
Q(document__accesses__user=user)
| Q(document__accesses__team__in=teams),
Q(
document__accesses__user=user,
document__accesses__role__in=models.PRIVILEGED_ROLES,
)
| Q(
document__accesses__team__in=teams,
document__accesses__role__in=models.PRIVILEGED_ROLES,
),
)
# Abilities are computed based on logged-in user's role and
# the user role on each document access

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-10-25 11:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_fix_users_duplicate'),
]
operations = [
migrations.AlterField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
),
]

View File

@@ -72,6 +72,9 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
@@ -127,17 +130,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
regex=r"^[\w.@+-:]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
unique=True,
@@ -333,7 +336,7 @@ class Document(BaseModel):
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
default=LinkReachChoices.AUTHENTICATED,
default=LinkReachChoices.RESTRICTED,
)
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
@@ -493,7 +496,8 @@ class Document(BaseModel):
# Compute version roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
can_get_versions = bool(roles)
# Anonymous users should also not see document accesses
has_role = bool(roles)
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
@@ -508,18 +512,20 @@ class Document(BaseModel):
can_get = bool(roles)
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_role,
"ai_transform": is_owner_or_admin or is_editor,
"ai_translate": is_owner_or_admin or is_editor,
"attachment_upload": is_owner_or_admin or is_editor,
"destroy": RoleChoices.OWNER in roles,
"link_configuration": is_owner_or_admin,
"manage_accesses": is_owner_or_admin,
"invite_owner": RoleChoices.OWNER in roles,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
"update": is_owner_or_admin or is_editor,
"versions_destroy": is_owner_or_admin,
"versions_list": can_get_versions,
"versions_retrieve": can_get_versions,
"versions_list": has_role,
"versions_retrieve": has_role,
}
def email_invitation(self, language, email, role, sender):
@@ -675,7 +681,7 @@ class Template(BaseModel):
return {
"destroy": RoleChoices.OWNER in roles,
"generate_document": can_get,
"manage_accesses": is_owner_or_admin,
"accesses_manage": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
@@ -880,8 +886,6 @@ class Invitation(BaseModel):
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
can_update = False
roles = []
if user.is_authenticated:
@@ -896,17 +900,13 @@ class Invitation(BaseModel):
except (self._meta.model.DoesNotExist, IndexError):
roles = []
can_delete = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
can_update = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_admin_or_owner = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": can_delete,
"update": can_update,
"partial_update": can_update,
"retrieve": bool(roles),
"destroy": is_admin_or_owner,
"update": is_admin_or_owner,
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
}

View File

@@ -72,6 +72,8 @@ class AIService:
json_response = json.loads(sanitized_content)
raise RuntimeError("Error Test Sentry")
if "answer" not in json_response:
raise RuntimeError("AI response does not contain an answer")

View File

@@ -47,6 +47,7 @@ def test_api_documents_create_authenticated_success():
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "my document"
assert document.link_reach == "restricted"
assert document.accesses.filter(role="owner", user=user).exists()

View File

@@ -21,12 +21,14 @@ def test_api_documents_retrieve_anonymous_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"destroy": False,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -77,12 +79,14 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"link_configuration": False,
"destroy": False,
"manage_accesses": False,
"invite_owner": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",

View File

@@ -22,7 +22,7 @@ def test_api_templates_retrieve_anonymous_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -68,7 +68,7 @@ def test_api_templates_retrieve_authenticated_unrelated_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,

View File

@@ -69,6 +69,48 @@ def test_api_users_list_query_email():
assert user_ids == [str(nicole.id), str(frank.id)]
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by similarity"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
alice = factories.UserFactory(email="alice.johnson@example.gouv.fr")
factories.UserFactory(email="jane.smith@example.gouv.fr")
michael_wilson = factories.UserFactory(email="michael.wilson@example.gouv.fr")
factories.UserFactory(email="david.jones@example.gouv.fr")
michael_brown = factories.UserFactory(email="michael.brown@example.gouv.fr")
factories.UserFactory(email="sophia.taylor@example.gouv.fr")
response = client.get(
"/api/v1.0/users/?q=michael.johnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id), str(alice.id), str(michael_brown.id)]
response = client.get(
"/api/v1.0/users/?q=ajohnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(alice.id)]
response = client.get(
"/api/v1.0/users/?q=michael.wilson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users

View File

@@ -83,12 +83,14 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"link_configuration": False,
"destroy": False,
"manage_accesses": False,
"invite_owner": False,
"partial_update": False,
"retrieve": False,
"update": False,
@@ -115,12 +117,14 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"manage_accesses": False,
"invite_owner": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -147,12 +151,14 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"manage_accesses": False,
"invite_owner": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -168,12 +174,14 @@ def test_models_documents_get_abilities_owner():
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": True,
"link_configuration": True,
"manage_accesses": True,
"invite_owner": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -188,12 +196,14 @@ def test_models_documents_get_abilities_administrator():
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": True,
"manage_accesses": True,
"invite_owner": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -211,12 +221,14 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"manage_accesses": False,
"invite_owner": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -236,12 +248,14 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"manage_accesses": False,
"invite_owner": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -262,12 +276,14 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"manage_accesses": False,
"invite_owner": False,
"partial_update": False,
"retrieve": True,
"update": False,

View File

@@ -2,10 +2,12 @@
Unit tests for the Invitation model
"""
import time
from datetime import timedelta
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import exceptions
from django.utils import timezone
import pytest
from faker import Faker
@@ -60,7 +62,7 @@ def test_models_invitations_role_among_choices():
factories.InvitationFactory(role="boss")
def test_models_invitations__is_expired(settings):
def test_models_invitations_is_expired():
"""
The 'is_expired' property should return False until validity duration
is exceeded and True afterwards.
@@ -68,13 +70,16 @@ def test_models_invitations__is_expired(settings):
expired_invitation = factories.InvitationFactory()
assert expired_invitation.is_expired is False
settings.INVITATION_VALIDITY_DURATION = 1
time.sleep(1)
not_late = timezone.now() + timedelta(seconds=604799)
with mock.patch("django.utils.timezone.now", return_value=not_late):
assert expired_invitation.is_expired is False
assert expired_invitation.is_expired is True
too_late = timezone.now() + timedelta(seconds=604800) # 7 days
with mock.patch("django.utils.timezone.now", return_value=too_late):
assert expired_invitation.is_expired is True
def test_models_invitation__new_user__convert_invitations_to_accesses():
def test_models_invitationd_new_userd_convert_invitations_to_accesses():
"""
Upon creating a new user, invitations linked to the email
should be converted to accesses and then deleted.
@@ -109,7 +114,7 @@ def test_models_invitation__new_user__convert_invitations_to_accesses():
).exists() # the other invitation remains
def test_models_invitation__new_user__filter_expired_invitations():
def test_models_invitationd_new_user_filter_expired_invitations():
"""
Upon creating a new identity, valid invitations should be converted into accesses
and expired invitations should remain unchanged.
@@ -140,7 +145,7 @@ def test_models_invitation__new_user__filter_expired_invitations():
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 6), (20, 6)])
def test_models_invitation__new_user__user_creation_constant_num_queries(
def test_models_invitationd_new_userd_user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):
"""
@@ -235,7 +240,7 @@ def test_models_document_invitations_get_abilities_reader(via, mock_user_teams):
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"partial_update": False,
"update": False,
}
@@ -260,7 +265,7 @@ def test_models_document_invitations_get_abilities_editor(via, mock_user_teams):
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"partial_update": False,
"update": False,
}

View File

@@ -62,7 +62,7 @@ def test_models_templates_get_abilities_anonymous_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -76,7 +76,7 @@ def test_models_templates_get_abilities_anonymous_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -90,7 +90,7 @@ def test_models_templates_get_abilities_authenticated_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -104,7 +104,7 @@ def test_models_templates_get_abilities_authenticated_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -119,7 +119,7 @@ def test_models_templates_get_abilities_owner():
"destroy": True,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -133,7 +133,7 @@ def test_models_templates_get_abilities_administrator():
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -150,7 +150,7 @@ def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": True,
"generate_document": True,
}
@@ -167,7 +167,7 @@ def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -185,7 +185,7 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}

View File

@@ -10,14 +10,17 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
import tomllib
from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from sentry_sdk.integrations.django import DjangoIntegration
from logging import getLogger
logger = getLogger(__name__)
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -27,19 +30,12 @@ DATA_DIR = os.path.join("/", "data")
def get_release():
"""
Get the current release of the application
By release, we mean the release from the version.json file à la Mozilla [1]
(if any). If this file has not been found, it defaults to "NA".
[1]
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
"""
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
pyproject_data = tomllib.load(f)
return pyproject_data["project"]["version"]
except (FileNotFoundError, KeyError):
return "NA" # Default: not available
@@ -63,7 +59,7 @@ class Base(Configuration):
* DB_USER
"""
DEBUG = False
DEBUG = True
USE_SWAGGER = False
API_VERSION = "v1.0"
@@ -223,6 +219,7 @@ class Base(Configuration):
# Languages
LANGUAGE_CODE = values.Value("en-us")
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
DRF_NESTED_MULTIPART_PARSER = {
# output of parser is converted to querydict
@@ -236,6 +233,7 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
)
)
@@ -370,7 +368,8 @@ class Base(Configuration):
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
SENTRY_ENV = values.Value(None, environ_name="SENTRY_ENV", environ_prefix=None)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -520,6 +519,10 @@ class Base(Configuration):
# The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None:
logger.debug("Sentry is SENTRY_ENV. %s ", cls.SENTRY_ENV)
logger.debug("Sentry is cls.__name__.lower(): %s ", cls.__name__.lower())
print("Sentry is cls.__name__.lower(): %s ", cls.__name__.lower())
print("Sentry is SENTRY_ENV. %s ", cls.SENTRY_ENV)
sentry_sdk.init(
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(),

Binary file not shown.

View File

@@ -0,0 +1,349 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
"PO-Revision-Date: 2024-09-25 10:21\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\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-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:32
msgid "Personal info"
msgstr "Persönliche Angaben"
#: core/admin.py:34
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:46
msgid "Important dates"
msgstr "Wichtige Termine"
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:56
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:101
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Leser"
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Bearbeiter"
#: core/models.py:71
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:72
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:80
msgid "Restricted"
msgstr "Eingeschränkt"
#: core/models.py:84
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:86
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:98
msgid "id"
msgstr ""
#: core/models.py:99
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
msgid "created on"
msgstr ""
#: core/models.py:106
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
msgid "updated on"
msgstr ""
#: core/models.py:112
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:148
msgid "identity email address"
msgstr ""
#: core/models.py:153
msgid "admin email address"
msgstr ""
#: core/models.py:160
msgid "language"
msgstr ""
#: core/models.py:161
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:167
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
msgid "device"
msgstr ""
#: core/models.py:172
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
msgid "staff status"
msgstr ""
#: core/models.py:177
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
msgid "active"
msgstr ""
#: core/models.py:183
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
msgid "user"
msgstr ""
#: core/models.py:196
msgid "users"
msgstr ""
#: core/models.py:328 core/models.py:644
msgid "title"
msgstr ""
#: core/models.py:343
msgid "Document"
msgstr ""
#: core/models.py:344
msgid "Documents"
msgstr ""
#: core/models.py:347
msgid "Untitled Document"
msgstr ""
#: core/models.py:537
#, python-format
msgid "%(username)s shared a document with you: %(document)s"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt: %(document)s"
#: core/models.py:580
msgid "Document/user link trace"
msgstr ""
#: core/models.py:581
msgid "Document/user link traces"
msgstr ""
#: core/models.py:587
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:608
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
msgid "This user is already in this document."
msgstr ""
#: core/models.py:621
msgid "This team is already in this document."
msgstr ""
#: core/models.py:627 core/models.py:816
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:645
msgid "description"
msgstr ""
#: core/models.py:646
msgid "code"
msgstr ""
#: core/models.py:647
msgid "css"
msgstr ""
#: core/models.py:649
msgid "public"
msgstr ""
#: core/models.py:651
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:657
msgid "Template"
msgstr ""
#: core/models.py:658
msgid "Templates"
msgstr ""
#: core/models.py:797
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
msgid "email address"
msgstr ""
#: core/models.py:850
msgid "Document invitation"
msgstr ""
#: core/models.py:851
msgid "Document invitations"
msgstr ""
#: core/models.py:868
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(username)s shared a document with you ! "
msgstr " %(username)s hat ein Dokument mit Ihnen geteilt! "
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(username)s invited you as an %(role)s on the following document : "
msgstr " %(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen: "
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgstr " Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und Zusammenarbeiten an Dokumenten im Team. "
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr "Bereitgestellt von La Suite Numérique"
#: core/templates/mail/html/invitation2.html:190
#, python-format
msgid "%(username)s shared a document with you"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt"
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr "%(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen:"
#: core/templates/mail/html/invitation2.html:228
#: core/templates/mail/text/invitation2.txt:15
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
msgstr "Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und gemeinsamen Arbeiten an Dokumenten im Team."
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -345,11 +345,14 @@ msgstr ""
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -345,11 +345,14 @@ msgstr "Proposé par La Suite Numérique"
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.6.0"
version = "1.7.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.41",
"boto3==1.35.44",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
@@ -46,14 +46,14 @@ dependencies = [
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.44.1",
"openai==1.52.0",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"pypandoc==1.14",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.16.0",
"sentry-sdk==2.17.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
@@ -82,7 +82,7 @@ dev = [
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.6.9",
"ruff==0.7.0",
"types-requests==2.32.0.20241016",
]
@@ -127,6 +127,7 @@ select = [
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
sections = { impress=["core"], django=["django"] }
extra-standard-library = ["tomllib"]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]

View File

@@ -1,5 +0,0 @@
{
"semi": false,
"trailingComma": "es5",
"singleQuote": true
}

View File

@@ -1,28 +0,0 @@
FROM node:20-alpine AS dependencies
WORKDIR /home/blocknote
COPY ./src/blocknote/package*.json ./
RUN npm install
COPY .dockerignore ./.dockerignore
COPY ./src/blocknote/ .
FROM dependencies AS blocknote-builder
WORKDIR /home/blocknote
RUN npm run build
# ---- Blocknote image ----
FROM blocknote-builder AS production
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT ["/usr/local/bin/entrypoint"]
CMD ["npm", "run", "start"]

View File

@@ -1,5 +0,0 @@
{
"watch": ["src"],
"ext": "ts",
"exec": "concurrently \"npx tsc --watch\" \"ts-node src/index.ts\""
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
{
"name": "blocknote-server",
"version": "1.0.0",
"license": "MIT",
"main": "dist/index.js",
"keywords": [
"nodejs",
"bootstrap",
"express"
],
"scripts": {
"build": "npx tsc",
"start": "node dist/index.js",
"dev": "nodemon src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1",
"format": "prettier --write ./src",
"check": "prettier --check ./src"
},
"dependencies": {
"@blocknote/server-util": "0.17.1",
"dotenv": "16.4.5",
"express": "4.21.1",
"prettier": "3.3.3",
"yjs": "13.6.20"
},
"devDependencies": {
"@types/express": "5.0.0",
"@types/node": "22.7.7",
"concurrently": "9.0.1",
"nodemon": "3.1.7",
"ts-node": "10.9.2",
"typescript": "5.6.3",
"prettier": "3.3.3"
}
}

View File

@@ -1,37 +0,0 @@
import express, { Express, Request, Response } from 'express'
import { asyncWrapper, convertMarkdown } from './utils'
import dotenv from 'dotenv'
import bodyParser from 'body-parser'
dotenv.config()
const app: Express = express()
const router = express.Router()
const port = process.env.PORT ?? 8081
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
// Logging middleware, logs the request method and path for each incoming request
router.use(async function (req, res, next) {
console.log(`/${req.method}`)
next()
})
// Liveness probe endpoint for Kubernetes health checks
router.get('/__heartbeat__', (req: Request, res: Response) => {
res.status(200).send({ status: 'OK' })
})
// Load balancer heartbeat check, useful to detect app readiness
router.get('/__lbheartbeat__', (req: Request, res: Response) => {
res.status(200).send({ status: 'OK' })
})
router.post('/', asyncWrapper(convertMarkdown))
app.use('/', router)
app.listen(port, () => {
console.log(`[server]: Server listening on port ${port}`)
})

View File

@@ -1,60 +0,0 @@
// Utility functions for handling markdown conversion and related operations
import { NextFunction, Request, Response } from 'express'
import { ServerBlockNoteEditor } from '@blocknote/server-util'
import Y from 'yjs'
const toBase64 = function (str: Uint8Array) {
return Buffer.from(str).toString('base64')
}
export const asyncWrapper = (
asyncFn: (req: Request, res: Response) => Promise<Response>
) => {
return function (req: Request, res: Response, next: NextFunction) {
asyncFn(req, res).catch(next)
}
}
const validateContent = (content: string | undefined): string => {
if (!content) {
throw new Error('Content is required')
}
return content
}
const parseMarkdownToBlocks = async (
blockNoteEditor: ServerBlockNoteEditor,
content: string
) => {
try {
const blocks = await blockNoteEditor.tryParseMarkdownToBlocks(content)
if (!blocks || blocks.length === 0) {
throw new Error('No valid blocks generated')
}
return blocks
} catch (error) {
throw new Error('Failed to parse markdown content')
}
}
const processContentBlocks = (server: ServerBlockNoteEditor, blocks: any[]) => {
try {
const yDocument = server.blocksToYDoc(blocks, 'document-store')
return toBase64(Y.encodeStateAsUpdate(yDocument))
} catch (error) {
throw new Error('Failed to process content blocks')
}
}
export const convertMarkdown = async (req: Request, res: Response) => {
try {
const content = validateContent(req.body.content)
const editor = ServerBlockNoteEditor.create()
const blocks = await parseMarkdownToBlocks(editor, content)
const encodedContent = processContentBlocks(editor, blocks)
return res.send({ content: encodedContent })
} catch (error) {
return res.status(500).json({ error: (error as Error).message })
}
}

View File

@@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

View File

@@ -76,6 +76,12 @@ ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
ARG SENTRY_DSN
ENV NEXT_PUBLIC_SENTRY_DSN=${SENTRY_DSN}
ARG SENTRY_ENV
ENV NEXT_PUBLIC_SENTRY_ENV=${SENTRY_ENV}
RUN yarn build
# ---- Front-end image ----

View File

@@ -4,17 +4,17 @@ export const keyCloakSignIn = async (page: Page, browserName: string) => {
const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`;
await expect(
page.locator('.login-pf-page-header').getByText('impress'),
).toBeVisible();
if (await page.getByLabel('Restart login').isVisible()) {
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
} else {
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
await page.getByLabel('Restart login').click();
}
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
};
export const randomName = (name: string, browserName: string, length: number) =>
@@ -27,7 +27,6 @@ export const createDoc = async (
docName: string,
browserName: string,
length: number,
isPublic: boolean = false,
) => {
const randomDocs = randomName(docName, browserName, length);
@@ -44,22 +43,6 @@ export const createDoc = async (
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
if (isPublic) {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByText('Doc private').click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
force: true,
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public'),
).toBeVisible();
}
}
return randomDocs;
@@ -161,7 +144,7 @@ export const mockedDocument = async (page: Page, json: object) => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,

View File

@@ -9,6 +9,78 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Editor', () => {
test('it check translations of the slash menu when changing language', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const header = page.locator('header').first();
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show english menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
// Reset menu
await editor.click();
await editor.fill('');
// Change language to French
await header.click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
// Trigger slash menu to show french menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Titres', { exact: true })).toBeHidden();
});
test('it checks default toolbar buttons are displayed', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('test content');
await editor.getByText('test content').dblclick();
const toolbar = page.locator('.bn-formatting-toolbar');
await expect(toolbar.locator('button[data-test="bold"]')).toBeVisible();
await expect(toolbar.locator('button[data-test="italic"]')).toBeVisible();
await expect(
toolbar.locator('button[data-test="underline"]'),
).toBeVisible();
await expect(toolbar.locator('button[data-test="strike"]')).toBeVisible();
await expect(
toolbar.locator('button[data-test="alignTextLeft"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="alignTextCenter"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="alignTextRight"]'),
).toBeVisible();
await expect(toolbar.locator('button[data-test="colors"]')).toBeVisible();
await expect(
toolbar.locator('button[data-test="unnestBlock"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="createLink"]'),
).toBeVisible();
});
test('checks the Doc is connected to the provider server', async ({
page,
browserName,
@@ -59,9 +131,10 @@ test.describe('Doc Editor', () => {
test('it renders correctly when we switch from one doc to another', async ({
page,
browserName,
}) => {
// Check the first doc
const firstDoc = await goToGridDoc(page);
const [firstDoc] = await createDoc(page, 'doc-switch-1', browserName, 1);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
@@ -70,9 +143,7 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
// Check the second doc
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
const [secondDoc] = await createDoc(page, 'doc-switch-2', browserName, 1);
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await editor.click();
@@ -88,9 +159,12 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
});
test('it saves the doc when we change pages', async ({ page }) => {
test('it saves the doc when we change pages', async ({
page,
browserName,
}) => {
// Check the first doc
const doc = await goToGridDoc(page);
const [doc] = await createDoc(page, 'doc-saves-change', browserName, 1);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
@@ -141,7 +215,7 @@ test.describe('Doc Editor', () => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,

View File

@@ -303,7 +303,7 @@ test.describe('Documents Grid mobile', () => {
attachment_upload: true,
destroy: true,
link_configuration: true,
manage_accesses: true,
accesses_manage: true,
partial_update: true,
retrieve: true,
update: true,

View File

@@ -21,6 +21,7 @@ test.describe('Doc Header', () => {
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
{
@@ -44,7 +45,7 @@ test.describe('Doc Header', () => {
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
accesses_manage: true,
update: true,
partial_update: true,
retrieve: true,
@@ -65,7 +66,7 @@ test.describe('Doc Header', () => {
card.getByText('Created at 09/01/2021, 11:00 AM'),
).toBeVisible();
await expect(
card.getByText('Owners: super@owner.com / super2@owner.com'),
card.getByText('Owners: Super Owner / super2@owner.com'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
@@ -176,12 +177,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if administrator', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: true, // Means admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
@@ -211,7 +213,11 @@ test.describe('Doc Header', () => {
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).not.toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeVisible();
const invitationCard = shareModal.getByLabel('List invitation card');
@@ -242,12 +248,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if editor', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: false, // Means not admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
@@ -284,7 +291,11 @@ test.describe('Doc Header', () => {
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
@@ -315,12 +326,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if reader', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: false, // Means not admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
@@ -357,7 +369,11 @@ test.describe('Doc Header', () => {
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
@@ -476,7 +492,7 @@ test.describe('Documents Header mobile', () => {
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
accesses_manage: true,
update: true,
partial_update: true,
retrieve: true,

View File

@@ -25,6 +25,7 @@ test.describe('Document list members', () => {
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
full_name: `Impress World Page ${pageId}-${i}`,
},
team: '',
role: 'editor',
@@ -58,9 +59,11 @@ test.describe('Document list members', () => {
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(list.getByText(`Impress World Page 1-16`)).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(list.getByText(`Impress World Page 2-15`)).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
@@ -164,14 +167,22 @@ test.describe('Document list members', () => {
const shareModal = page.getByLabel('Share modal');
// Admin still have the right to share
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).not.toHaveAttribute('disabled');
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Reader does not have the right to share
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
});
test('it checks the delete members', async ({ page, browserName }) => {

View File

@@ -7,6 +7,22 @@ test.describe('Doc Routing', () => {
await page.goto('/');
});
test('Check the presence of the meta tag noindex', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
await buttonCreateHomepage.click();
await expect(
page.getByRole('button', {
name: 'Share',
}),
).toBeVisible();
const metaDescription = page.locator('meta[name="robots"]');
await expect(metaDescription).toHaveAttribute('content', 'noindex');
});
test('checks alias docs url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');

View File

@@ -2,39 +2,13 @@ import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn } from './common';
const browsersName = ['chromium', 'webkit', 'firefox'];
test.describe('Doc Visibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('Make a public doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagridTable.getByText(docTitle)).toBeVisible();
const row = datagridTable.getByRole('row').filter({
hasText: docTitle,
});
await expect(row.getByRole('cell').nth(0)).toHaveText('Public');
});
test('It checks the copy link button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
@@ -56,12 +30,48 @@ test.describe('Doc Visibility', () => {
expect(clipboardContent).toMatch(page.url());
});
test('It checks the link role options', async ({ page, browserName }) => {
await createDoc(page, 'Doc role options', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByRole('combobox', {
name: 'Visibility',
});
await expect(selectVisibility.getByText('Restricted')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await selectVisibility.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
await selectVisibility.click();
await page
.getByRole('option', {
name: 'Public',
})
.click();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
});
});
test.describe('Doc Visibility: Not loggued', () => {
test.describe('Doc Visibility: Restricted', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A public doc is accessible even when not authentified.', async ({
test('A doc is not accessible when not authentified.', async ({
page,
browserName,
}) => {
@@ -70,14 +80,157 @@ test.describe('Doc Visibility: Not loggued', () => {
const [docTitle] = await createDoc(
page,
'My new doc',
'Restricted no auth',
browserName,
1,
true,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('A doc is not accessible when authentified but not member.', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(
page.getByText('The document visiblitity has been updated.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
test('A doc is accessible when member.', async ({ page, browserName }) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const otherBrowser = browsersName.find((b) => b !== browserName);
const username = `user@${otherBrowser}.e2e`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`User ${username} added to the document.`),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
test.describe('Doc Visibility: Public', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It checks a public doc in read only mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Public read only',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Read only').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -94,19 +247,54 @@ test.describe('Doc Visibility: Not loggued', () => {
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
});
test('A private doc redirect to the OIDC when not authentified.', async ({
test('It checks a public doc in editable mode', async ({
page,
browserName,
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'My private doc', browserName, 1);
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Can read and edit').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -116,10 +304,214 @@ test.describe('Doc Visibility: Not loggued', () => {
})
.click();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeHidden();
});
});
test.describe('Doc Visibility: Authenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A doc is not accessible when unauthentified.', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Authenticated unauthentified',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeHidden();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('It checks a authenticated doc in read only mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Authenticated read only',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
test('It checks a authenticated doc in editable mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Authenticated editable',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Can read and edit').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeHidden();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
});

View File

@@ -24,5 +24,51 @@ test.describe('Language', () => {
name: 'Créer un nouveau document',
}),
).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Neues Dokument erstellen',
}),
).toBeVisible();
});
test('checks that backend uses the same language as the frontend', async ({
page,
}) => {
// Helper function to intercept and assert 404 response
const check404Response = async (expectedDetail: string) => {
const expectedBackendResponse = page.waitForResponse(
(response) =>
response.url().includes('/api') &&
response.url().includes('non-existent-doc-uuid') &&
response.status() === 404,
);
// Trigger the specific 404 XHR response by navigating to a non-existent document
await page.goto('/docs/non-existent-doc-uuid');
// Assert that the intercepted error message is in the expected language
const interceptedBackendResponse = await expectedBackendResponse;
expect(await interceptedBackendResponse.json()).toStrictEqual({
detail: expectedDetail,
});
};
// Check for English 404 response
await check404Response('Not found.');
// Switch language to French
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
// Check for French 404 response
await check404Response('Pas trouvé.');
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.6.0",
"version": "1.7.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",

View File

@@ -3,3 +3,5 @@ NEXT_PUBLIC_Y_PROVIDER_URL=
NEXT_PUBLIC_MEDIA_URL=
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_SW_DEACTIVATED=
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_ENV=production

View File

@@ -2,3 +2,4 @@ NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_Y_PROVIDER_URL=ws://localhost:4444
NEXT_PUBLIC_MEDIA_URL=http://localhost:8083
NEXT_PUBLIC_SW_DEACTIVATED=true
NEXT_PUBLIC_SENTRY_ENV=development

View File

@@ -358,6 +358,8 @@ const config = {
},
'forms-field': {
color: 'var(--c--theme--colors--primary-text)',
'footer-font-size': 'var(--c--theme--font--sizes--t)',
'footer-color': 'var(--c--theme--colors--greyscale-text)',
},
'forms-input': {
'border-radius': '4px',
@@ -372,6 +374,9 @@ const config = {
big: 'var(--c--theme--colors--primary-text)',
},
},
'forms-radio': {
'accent-color': 'var(--c--theme--colors--primary-600)',
},
'forms-select': {
'item-font-size': '14px',
'border-radius': '4px',

View File

@@ -1,5 +1,6 @@
const crypto = require('crypto');
const { withSentryConfig } = require('@sentry/nextjs');
const { InjectManifest } = require('workbox-webpack-plugin');
const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
@@ -65,4 +66,6 @@ const nextConfig = {
},
};
module.exports = nextConfig;
module.exports = withSentryConfig(nextConfig, {
silent: false,
});

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.6.0",
"version": "1.7.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -21,8 +21,10 @@
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.7",
"@openfun/cunningham-react": "2.9.4",
"@sentry/nextjs": "8.37.1",
"@tanstack/react-query": "5.59.15",
"i18next": "23.16.0",
"i18next": "23.16.2",
"i18next-browser-languagedetector": "8.0.0",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
@@ -41,11 +43,11 @@
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.59.15",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.1",
"@testing-library/jest-dom": "6.6.2",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.10",
"@types/lodash": "4.17.12",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.11",

View File

@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/nextjs';
import packageJson from './package.json';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENV,
integrations: [Sentry.replayIntegration()],
release: packageJson.version,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
tracesSampleRate: 1.0,
});

View File

@@ -19,7 +19,7 @@ export interface BoxProps {
$direction?: CSSProperties['flexDirection'];
$display?: CSSProperties['display'];
$effect?: 'show' | 'hide';
$flex?: boolean;
$flex?: CSSProperties['flex'];
$gap?: CSSProperties['gap'];
$hasTransition?: boolean | 'slow';
$height?: CSSProperties['height'];
@@ -50,7 +50,7 @@ export const Box = styled('div')<BoxProps>`
${({ $color }) => $color && `color: ${$color};`}
${({ $direction }) => $direction && `flex-direction: ${$direction};`}
${({ $display }) => $display && `display: ${$display};`}
${({ $flex }) => $flex === false && `display: block;`}
${({ $flex }) => $flex && `flex: ${$flex};`}
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
${({ $hasTransition }) =>

View File

@@ -1,4 +1,9 @@
import { CSSProperties, ComponentPropsWithRef, ReactHTML } from 'react';
import {
CSSProperties,
ComponentPropsWithRef,
ReactHTML,
forwardRef,
} from 'react';
import styled from 'styled-components';
import { tokens } from '@/cunningham';
@@ -55,18 +60,21 @@ export const TextStyled = styled(Box)<TextProps>`
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`}
`;
export const Text = ({
className,
$isMaterialIcon,
...props
}: ComponentPropsWithRef<typeof TextStyled>) => {
return (
<TextStyled
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
);
};
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
({ className, $isMaterialIcon, ...props }, ref) => {
return (
<TextStyled
ref={ref}
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
);
},
);
Text.displayName = 'Text';
export { Text };

View File

@@ -8,4 +8,6 @@
export interface User {
id: string;
email: string;
full_name: string;
short_name: string;
}

View File

@@ -16,6 +16,12 @@
line-height: initial;
}
.c__field .c__field__footer {
padding: 2px 0 0;
font-size: var(--c--components--forms-field--footer-font-size);
color: var(--c--components--forms-field--footer-color);
}
.labelled-box label {
color: var(--c--theme--colors--primary-text);
}
@@ -328,6 +334,10 @@ input:-webkit-autofill:focus {
cursor: not-allowed;
}
.c__checkbox.c__checkbox--disabled .c__checkbox__label {
color: var(--c--theme--colors--greyscale-400);
}
/**
* Button
*/
@@ -532,3 +542,10 @@ input:-webkit-autofill:focus {
.c__toast__container {
z-index: 10000;
}
/**
* Tooltip
*/
.c__tooltip {
padding: 4px 6px;
}

View File

@@ -477,6 +477,12 @@
--c--components--forms-datepicker--border-radius: 0;
--c--components--forms-fileuploader--border-radius: 0;
--c--components--forms-field--color: var(--c--theme--colors--primary-text);
--c--components--forms-field--footer-font-size: var(
--c--theme--font--sizes--t
);
--c--components--forms-field--footer-color: var(
--c--theme--colors--greyscale-text
);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--background-color: #fff;
--c--components--forms-input--border-color: var(
@@ -492,6 +498,9 @@
--c--components--forms-labelledbox--label-color--big: var(
--c--theme--colors--primary-text
);
--c--components--forms-radio--accent-color: var(
--c--theme--colors--primary-600
);
--c--components--forms-select--item-font-size: 14px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius-hover: 4px;

View File

@@ -479,7 +479,11 @@ export const tokens = {
},
'forms-datepicker': { 'border-radius': '0' },
'forms-fileuploader': { 'border-radius': '0' },
'forms-field': { color: 'var(--c--theme--colors--primary-text)' },
'forms-field': {
color: 'var(--c--theme--colors--primary-text)',
'footer-font-size': 'var(--c--theme--font--sizes--t)',
'footer-color': 'var(--c--theme--colors--greyscale-text)',
},
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
@@ -491,6 +495,9 @@ export const tokens = {
'forms-labelledbox': {
'label-color': { big: 'var(--c--theme--colors--primary-text)' },
},
'forms-radio': {
'accent-color': 'var(--c--theme--colors--primary-600)',
},
'forms-select': {
'item-font-size': '14px',
'border-radius': '4px',

View File

@@ -9,26 +9,18 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import {
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { PropsWithChildren, ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { isAPIError } from '@/api';
import { Box, Text } from '@/components';
import { useDocOptions } from '@/features/docs/doc-management/';
import { useDocOptions, useDocStore } from '@/features/docs/doc-management/';
import {
AITransformActions,
useDocAITransform,
useDocAITranslate,
} from '../api/';
import { useDocStore } from '../stores';
type LanguageTranslate = {
value: string;
@@ -70,9 +62,8 @@ export function AIGroupButton() {
const { t } = useTranslation();
const { currentDoc } = useDocStore();
const { data: docOptions } = useDocOptions();
const [languages, setLanguages] = useState<LanguageTranslate[]>([]);
useEffect(() => {
const languages = useMemo(() => {
const languages = docOptions?.actions.POST.language.choices;
if (!languages) {
@@ -90,7 +81,7 @@ export function AIGroupButton() {
'pl',
]);
setLanguages(languages);
return languages;
}, [docOptions?.actions.POST.language.choices]);
const show = useMemo(() => {
@@ -220,45 +211,19 @@ const AIMenuItemTransform = ({
children,
icon,
}: PropsWithChildren<AIMenuItemTransform>) => {
const editor = useBlockNoteEditor();
const { mutateAsync: requestAI, isPending } = useDocAITransform();
const handleAIError = useHandleAIError();
const handleAIAction = useCallback(async () => {
const selectedBlocks = editor.getSelection()?.blocks;
if (!selectedBlocks || selectedBlocks.length === 0) {
return;
}
const markdown = await editor.blocksToMarkdownLossy(selectedBlocks);
try {
const responseAI = await requestAI({
text: markdown,
action,
docId,
});
if (!responseAI.answer) {
return;
}
const blockMarkdown = await editor.tryParseMarkdownToBlocks(
responseAI.answer,
);
editor.replaceBlocks(selectedBlocks, blockMarkdown);
} catch (error) {
handleAIError(error);
}
}, [editor, requestAI, action, docId, handleAIError]);
const requestAIAction = async (markdown: string) => {
const responseAI = await requestAI({
text: markdown,
action,
docId,
});
return responseAI.answer;
};
return (
<AIMenuItem
icon={icon}
handleAIAction={handleAIAction}
isPending={isPending}
>
<AIMenuItem icon={icon} requestAI={requestAIAction} isPending={isPending}>
{children}
</AIMenuItem>
);
@@ -276,11 +241,46 @@ const AIMenuItemTranslate = ({
icon,
language,
}: PropsWithChildren<AIMenuItemTranslate>) => {
const editor = useBlockNoteEditor();
const { mutateAsync: requestAI, isPending } = useDocAITranslate();
const requestAITranslate = async (markdown: string) => {
const responseAI = await requestAI({
text: markdown,
language,
docId,
});
return responseAI.answer;
};
return (
<AIMenuItem
icon={icon}
requestAI={requestAITranslate}
isPending={isPending}
>
{children}
</AIMenuItem>
);
};
interface AIMenuItemProps {
requestAI: (markdown: string) => Promise<string>;
isPending: boolean;
icon?: ReactNode;
}
const AIMenuItem = ({
requestAI,
isPending,
children,
icon,
}: PropsWithChildren<AIMenuItemProps>) => {
const Components = useComponentsContext();
const editor = useBlockNoteEditor();
const handleAIError = useHandleAIError();
const handleAIAction = useCallback(async () => {
const handleAIAction = async () => {
const selectedBlocks = editor.getSelection()?.blocks;
if (!selectedBlocks || selectedBlocks.length === 0) {
@@ -290,49 +290,18 @@ const AIMenuItemTranslate = ({
const markdown = await editor.blocksToMarkdownLossy(selectedBlocks);
try {
const responseAI = await requestAI({
text: markdown,
language,
docId,
});
const responseAI = await requestAI(markdown);
if (!responseAI.answer) {
if (!responseAI) {
return;
}
const blockMarkdown = await editor.tryParseMarkdownToBlocks(
responseAI.answer,
);
const blockMarkdown = await editor.tryParseMarkdownToBlocks(responseAI);
editor.replaceBlocks(selectedBlocks, blockMarkdown);
} catch (error) {
handleAIError(error);
}
}, [editor, requestAI, language, docId, handleAIError]);
return (
<AIMenuItem
icon={icon}
handleAIAction={handleAIAction}
isPending={isPending}
>
{children}
</AIMenuItem>
);
};
interface AIMenuItemProps {
handleAIAction: () => Promise<void>;
isPending: boolean;
icon?: ReactNode;
}
const AIMenuItem = ({
handleAIAction,
isPending,
children,
icon,
}: PropsWithChildren<AIMenuItemProps>) => {
const Components = useComponentsContext();
};
if (!Components) {
return null;
@@ -359,26 +328,12 @@ const useHandleAIError = () => {
const { toast } = useToastProvider();
const { t } = useTranslation();
const handleAIError = useCallback(
(error: unknown) => {
if (isAPIError(error)) {
error.cause?.forEach((cause) => {
if (
cause === 'Request was throttled. Expected available in 60 seconds.'
) {
toast(
t('Too many requests. Please wait 60 seconds.'),
VariantType.ERROR,
);
}
});
}
return (error: unknown) => {
if (isAPIError(error) && error.status === 429) {
toast(t('Too many requests. Please wait 60 seconds.'), VariantType.ERROR);
return;
}
toast(t('AI seems busy! Please try again.'), VariantType.ERROR);
console.error(error);
},
[toast, t],
);
return handleAIError;
toast(t('AI seems busy! Please try again.'), VariantType.ERROR);
};
};

View File

@@ -1,19 +1,21 @@
import { BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
import { locales } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import React, { useCallback, useEffect, useMemo } from 'react';
import { t } from 'i18next';
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, TextErrors } from '@/components';
import { mediaUrl } from '@/core';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { Version } from '@/features/docs/doc-versioning/';
import { Doc, useDocStore } from '@/features/docs/doc-management';
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
import useSaveDoc from '../hook/useSaveDoc';
import { useDocStore, useHeadingStore } from '../stores';
import { useHeadingStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
@@ -24,7 +26,13 @@ const cssEditor = (readonly: boolean) => `
};
& .bn-editor {
padding-right: 30px;
${readonly && `padding-left: 30px;`}
${
readonly &&
`
padding-left: 30px;
pointer-events: none;
`
}
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
@@ -66,40 +74,16 @@ const cssEditor = (readonly: boolean) => `
`;
interface BlockNoteEditorProps {
doc: Doc;
version?: Version;
}
export const BlockNoteEditor = ({ doc, version }: BlockNoteEditorProps) => {
const { createProvider, docsStore } = useDocStore();
const storeId = version?.id || doc.id;
const initialContent = version?.content || doc.content;
const provider = docsStore?.[storeId]?.provider;
useEffect(() => {
if (!provider || provider.document.guid !== storeId) {
createProvider(storeId, initialContent);
}
}, [createProvider, initialContent, provider, storeId]);
if (!provider) {
return null;
}
return <BlockNoteContent doc={doc} provider={provider} storeId={storeId} />;
};
interface BlockNoteContentProps {
doc: Doc;
provider: HocuspocusProvider;
storeId: string;
}
export const BlockNoteContent = ({
export const BlockNoteEditor = ({
doc,
provider,
storeId,
}: BlockNoteContentProps) => {
}: BlockNoteEditorProps) => {
const isVersion = doc.id !== storeId;
const { userData } = useAuthStore();
const { setStore, docsStore } = useDocStore();
@@ -113,6 +97,8 @@ export const BlockNoteContent = ({
error: errorAttachment,
} = useCreateDocAttachment();
const { setHeadings, resetHeadings } = useHeadingStore();
const { i18n } = useTranslation();
const lang = i18n.language;
const uploadFile = useCallback(
async (file: File) => {
@@ -129,23 +115,21 @@ export const BlockNoteContent = ({
[createDocAttachment, doc.id],
);
const editor = useMemo(() => {
if (storedEditor) {
return storedEditor;
}
return BlockNoteEditorCore.create({
const editor = useCreateBlockNote(
{
collaboration: {
provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: userData?.email || 'Anonymous',
name: userData?.full_name || userData?.email || t('Anonymous'),
color: randomColor(),
},
},
dictionary: locales[lang as keyof typeof locales],
uploadFile,
});
}, [provider, storedEditor, uploadFile, userData?.email]);
},
[lang, provider, uploadFile, userData?.email, userData?.full_name],
);
useEffect(() => {
setStore(storeId, { editor });
@@ -176,7 +160,7 @@ export const BlockNoteContent = ({
)}
<BlockNoteView
editor={editor}
editor={storedEditor ?? editor}
formattingToolbar={false}
editable={!readOnly}
theme="light"

View File

@@ -1,14 +1,8 @@
import '@blocknote/mantine/style.css';
import {
BasicTextStyleButton,
BlockTypeSelect,
ColorStyleButton,
CreateLinkButton,
FormattingToolbar,
FormattingToolbarController,
NestBlockButton,
TextAlignButton,
UnnestBlockButton,
getFormattingToolbarItems,
} from '@blocknote/react';
import React from 'react';
@@ -18,42 +12,15 @@ import { MarkdownButton } from './MarkdownButton';
export const BlockNoteToolbar = () => {
return (
<FormattingToolbarController
formattingToolbar={() => (
formattingToolbar={({ blockTypeSelectItems }) => (
<FormattingToolbar>
<BlockTypeSelect key="blockTypeSelect" />
{getFormattingToolbarItems(blockTypeSelectItems)}
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
<BasicTextStyleButton basicTextStyle="bold" key="boldStyleButton" />
<BasicTextStyleButton
basicTextStyle="italic"
key="italicStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="underline"
key="underlineStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="strike"
key="strikeStyleButton"
/>
{/* Extra button to toggle code styles */}
<BasicTextStyleButton key="codeStyleButton" basicTextStyle="code" />
<TextAlignButton textAlignment="left" key="textAlignLeftButton" />
<TextAlignButton textAlignment="center" key="textAlignCenterButton" />
<TextAlignButton textAlignment="right" key="textAlignRightButton" />
<ColorStyleButton key="colorStyleButton" />
<NestBlockButton key="nestBlockButton" />
<UnnestBlockButton key="unnestBlockButton" />
<CreateLinkButton key="createLinkButton" />
</FormattingToolbar>
)}
/>

View File

@@ -1,13 +1,13 @@
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import React from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import { Doc, useDocStore } from '@/features/docs/doc-management';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
@@ -32,6 +32,13 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
const { colorsTokens } = useCunninghamTheme();
const { docsStore } = useDocStore();
const provider = docsStore?.[doc.id]?.provider;
if (!provider) {
return null;
}
return (
<>
<DocHeader doc={doc} versionId={versionId as Versions['version_id']} />
@@ -66,7 +73,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} />
<BlockNoteEditor doc={doc} storeId={doc.id} provider={provider} />
)}
{!isMobile && <IconOpenPanelEditor headings={headings} />}
</Card>
@@ -91,9 +98,21 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
docId: doc.id,
versionId,
});
const { createProvider, docsStore } = useDocStore();
const navigate = useNavigate();
useEffect(() => {
if (!version?.id) {
return;
}
const provider = docsStore?.[version.id]?.provider;
if (!provider || provider.document.guid !== version.id) {
createProvider(version.id, version.content);
}
}, [createProvider, docsStore, version]);
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
@@ -124,5 +143,11 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
);
}
return <BlockNoteEditor doc={doc} version={version} />;
const provider = docsStore?.[version.id]?.provider;
if (!provider) {
return null;
}
return <BlockNoteEditor doc={doc} storeId={version.id} provider={provider} />;
};

View File

@@ -6,6 +6,7 @@ import {
} from '@blocknote/react';
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Block = {
type: string;
@@ -42,6 +43,7 @@ export function MarkdownButton() {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const { t } = useTranslation();
const handleConvertMarkdown = () => {
const blocks = editor.getSelection()?.blocks;
@@ -75,7 +77,7 @@ export function MarkdownButton() {
return (
<Components.FormattingToolbar.Button
mainTooltip="Convert Markdown"
mainTooltip={t('Convert Markdown')}
onClick={handleConvertMarkdown}
>
M

View File

@@ -1,3 +1,2 @@
export * from './useDocStore';
export * from './useHeadingStore';
export * from './usePanelEditorStore';

View File

@@ -1,5 +1,3 @@
import * as Y from 'yjs';
export const randomColor = () => {
const randomInt = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
@@ -28,20 +26,3 @@ function hslToHex(h: number, s: number, l: number) {
export const toBase64 = (
str: WithImplicitCoercion<ArrayBuffer | SharedArrayBuffer>,
) => Buffer.from(str).toString('base64');
type BasicBlock = {
type: string;
content: string;
};
export const blocksToYDoc = (blocks: BasicBlock[], doc: Y.Doc) => {
const xmlFragment = doc.getXmlFragment('document-store');
blocks.forEach((block) => {
const xmlElement = new Y.XmlElement(block.type);
if (block.content) {
xmlElement.insert(0, [new Y.XmlText(block.content)]);
}
xmlFragment.push([xmlElement]);
});
};

View File

@@ -104,7 +104,7 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
)
.map((access, index, accesses) => (
<Fragment key={`access-${index}`}>
{access.user.email}{' '}
{access.user.full_name || access.user.email}{' '}
{index < accesses.length - 1 ? ' / ' : ''}
</Fragment>
))}

View File

@@ -7,7 +7,6 @@ import {
} from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
@@ -19,15 +18,9 @@ import {
useTrans,
useUpdateDoc,
} from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useBroadcastStore, useResponsiveStore } from '@/stores';
import { isFirefox } from '@/utils/userAgent';
const DocTitleStyle = createGlobalStyle`
.c__tooltip {
padding: 4px 6px;
}
`;
interface DocTitleProps {
doc: Doc;
}
@@ -61,6 +54,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const headingText = headings?.[0]?.contentText;
const debounceRef = useRef<NodeJS.Timeout>();
const { isMobile } = useResponsiveStore();
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
@@ -68,6 +62,9 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);
}
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${data.id}`);
},
});
@@ -126,7 +123,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
return (
<>
<DocTitleStyle />
<Tooltip content={t('Rename')} placement="top">
<Box
as="h2"

View File

@@ -8,11 +8,12 @@ import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions } from '@/components';
import { useAuthStore } from '@/core';
import { useDocStore, usePanelEditorStore } from '@/features/docs/doc-editor/';
import { usePanelEditorStore } from '@/features/docs/doc-editor/';
import {
Doc,
ModalRemoveDoc,
ModalShare,
useDocStore,
} from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';

View File

@@ -14,8 +14,7 @@ import { t } from 'i18next';
import { useEffect, useMemo, useState } from 'react';
import { Box, Text } from '@/components';
import { useDocStore } from '@/features/docs/doc-editor/';
import { Doc } from '@/features/docs/doc-management';
import { Doc, useDocStore } from '@/features/docs/doc-management';
import { useExport } from '../api/useExport';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';

View File

@@ -5,7 +5,7 @@ export interface Template {
abilities: {
destroy: boolean;
generate_document: boolean;
manage_accesses: boolean;
accesses_manage: boolean;
retrieve: boolean;
update: boolean;
partial_update: boolean;

View File

@@ -1,7 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs';
import { Doc, KEY_DOC } from '@/features/docs/doc-management';
import { useBroadcastStore } from '@/stores';
export type UpdateDocLinkParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'link_role' | 'link_reach'>>;
@@ -37,14 +38,20 @@ export function useUpdateDocLink({
listInvalideQueries,
}: UpdateDocLinkProps = {}) {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Doc, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
onSuccess: (data) => {
onSuccess: (data, variable) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.resetQueries({
queryKey: [queryKey],
});
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.id}`);
onSuccess?.(data);
},
});

View File

@@ -1,16 +1,17 @@
import {
Button,
Switch,
Radio,
RadioGroup,
Select,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, IconBG } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { KEY_DOC, KEY_LIST_DOC, useUpdateDocLink } from '../api';
import { Doc, LinkReach } from '../types';
import { Doc, LinkReach, LinkRole } from '../types';
interface DocVisibilityProps {
doc: Doc;
@@ -18,14 +19,12 @@ interface DocVisibilityProps {
export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { t } = useTranslation();
const [docPublic, setDocPublic] = useState(
doc.link_reach === LinkReach.PUBLIC,
);
const { toast } = useToastProvider();
const { colorsTokens } = useCunninghamTheme();
const api = useUpdateDocLink({
onSuccess: () => {
toast(
t('The document visiblitity has been updated.'),
t('The document visibility has been updated.'),
VariantType.SUCCESS,
{
duration: 4000,
@@ -35,6 +34,34 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const transLinkReach = {
[LinkReach.RESTRICTED]: {
label: t('Restricted'),
description: t('Only for people with access'),
},
[LinkReach.AUTHENTICATED]: {
label: t('Authenticated'),
description: t('Only for authenticated users'),
},
[LinkReach.PUBLIC]: {
label: t('Public'),
description: t('Anyone on the internet with the link can view'),
},
};
const linkRoleList = [
{
label: t('Read only'),
value: LinkRole.READER,
},
{
label: t('Can read and edit'),
value: LinkRole.EDITOR,
},
];
const showLinkRoleOptions = doc.link_reach !== LinkReach.RESTRICTED;
return (
<Card
$margin="tiny"
@@ -44,55 +71,73 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
$align="center"
$justify="space-between"
$gap="1rem"
$wrap="wrap"
>
<IconBG iconName="public" $margin="none" />
<IconBG iconName="public" />
<Box
$width="100%"
$wrap="wrap"
$gap="1rem"
$justify="space-between"
$direction="row"
$align="center"
$flex="1"
$css={`
& .c__field__footer .c__field__text {
${!doc.abilities.link_configuration && `color: ${colorsTokens()['greyscale-400']};`};
}
`}
>
<Box $direction="row" $gap="1rem" $align="center">
<Switch
label={t(docPublic ? 'Doc public' : 'Doc private')}
defaultChecked={docPublic}
onChange={() => {
<Box $shrink="0" $flex="auto" $maxWidth="20rem">
<Select
label={t('Visibility')}
options={Object.values(LinkReach).map((linkReach) => ({
label: transLinkReach[linkReach].label,
value: linkReach,
}))}
onChange={(evt) =>
api.mutate({
link_reach: evt.target.value as LinkReach,
id: doc.id,
link_reach: docPublic ? LinkReach.RESTRICTED : LinkReach.PUBLIC,
link_role: 'reader',
});
setDocPublic(!docPublic);
}}
disabled={!doc.abilities.link_configuration}
text={
docPublic
? t('Anyone on the internet with the link can view')
: t('Only for people with access')
})
}
value={doc.link_reach}
clearable={false}
text={transLinkReach[doc.link_reach].description}
disabled={!doc.abilities.link_configuration}
/>
</Box>
<Button
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,
});
});
}}
color="primary"
icon={<span className="material-icons">copy</span>}
>
{t('Copy link')}
</Button>
{showLinkRoleOptions && (
<Box
$css={`
& .c__checkbox{
padding: 0.15rem 0.25rem;
}
`}
>
<RadioGroup
compact
style={{
display: 'flex',
}}
text={t('How people can interact with the document')}
>
{linkRoleList.map((radio) => (
<Radio
key={radio.value}
label={radio.label}
value={radio.value}
onChange={() =>
api.mutate({
link_role: radio.value,
id: doc.id,
})
}
checked={doc.link_role === radio.value}
disabled={!doc.abilities.link_configuration}
/>
))}
</RadioGroup>
</Box>
)}
</Box>
</Card>
);

View File

@@ -1,3 +1,8 @@
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { createGlobalStyle } from 'styled-components';
@@ -41,6 +46,7 @@ interface ModalShareProps {
export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
const { isMobile, isSmallMobile } = useResponsiveStore();
const width = isSmallMobile ? '100vw' : isMobile ? '90vw' : '70vw';
const { toast } = useToastProvider();
return (
<>
@@ -68,17 +74,48 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
iconName="share"
$margin="none"
/>
<Box $align="flex-start">
<Text as="h3" $size="26px" $margin="none">
{t('Share')}
</Text>
<Text $size="small" $weight="normal" $textAlign="left">
{doc.title}
</Text>
<Box
$justify="space-between"
$direction="row"
$align="center"
$width="100%"
$gap="1rem"
$wrap="wrap"
>
<Box $align="flex-start">
<Text as="h3" $size="26px" $margin="none">
{t('Share')}
</Text>
<Text $size="small" $weight="normal" $textAlign="left">
{doc.title}
</Text>
</Box>
<Box $margin={{ right: '1.5rem' }} $shrink="0">
<Button
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,
});
});
}}
color="primary"
icon={<span className="material-icons">copy</span>}
>
{t('Copy link')}
</Button>
</Box>
</Box>
</Card>
<DocVisibility doc={doc} />
{doc.abilities.manage_accesses && (
{doc.abilities.accesses_manage && (
<AddMembers
doc={doc}
currentRole={currentDocRole(doc.abilities)}
@@ -86,8 +123,12 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
)}
</Box>
<Box $minHeight="0">
<InvitationList doc={doc} />
<MemberList doc={doc} />
{doc.abilities.accesses_view && (
<>
<InvitationList doc={doc} />
<MemberList doc={doc} />
</>
)}
</Box>
</Box>
</SideModal>

View File

@@ -1,5 +1,6 @@
export * from './api';
export * from './components';
export * from './hooks';
export * from './stores';
export * from './types';
export * from './utils';

View File

@@ -0,0 +1 @@
export * from './useDocStore';

View File

@@ -4,9 +4,7 @@ import * as Y from 'yjs';
import { create } from 'zustand';
import { providerUrl } from '@/core';
import { Base64, Doc } from '@/features/docs/doc-management';
import { blocksToYDoc } from '../utils';
import { Base64, Doc, blocksToYDoc } from '@/features/docs/doc-management';
interface DocStore {
provider: HocuspocusProvider;

View File

@@ -27,6 +27,11 @@ export enum LinkReach {
AUTHENTICATED = 'authenticated',
}
export enum LinkRole {
READER = 'reader',
EDITOR = 'editor',
}
export type Base64 = string;
export interface Doc {
@@ -34,15 +39,16 @@ export interface Doc {
title: string;
content: Base64;
link_reach: LinkReach;
link_role: 'reader' | 'editor';
link_role: LinkRole;
accesses: Access[];
created_at: string;
updated_at: string;
abilities: {
accesses_manage: boolean;
accesses_view: boolean;
attachment_upload: true;
destroy: boolean;
link_configuration: boolean;
manage_accesses: boolean;
partial_update: boolean;
retrieve: boolean;
update: boolean;

View File

@@ -1,11 +1,30 @@
import * as Y from 'yjs';
import { Doc, Role } from './types';
export const currentDocRole = (abilities: Doc['abilities']): Role => {
return abilities.destroy
? Role.OWNER
: abilities.manage_accesses
: abilities.accesses_manage
? Role.ADMIN
: abilities.partial_update
? Role.EDITOR
: Role.READER;
};
type BasicBlock = {
type: string;
content: string;
};
export const blocksToYDoc = (blocks: BasicBlock[], doc: Y.Doc) => {
const xmlFragment = doc.getXmlFragment('document-store');
blocks.forEach((block) => {
const xmlElement = new Y.XmlElement(block.type);
if (block.content) {
xmlElement.insert(0, [new Y.XmlText(block.content)]);
}
xmlFragment.push([xmlElement]);
});
};

View File

@@ -2,8 +2,8 @@ import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { HeadingBlock, useDocStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { HeadingBlock } from '@/features/docs/doc-editor';
import { Doc, useDocStore } from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { Heading } from './Heading';

View File

@@ -11,8 +11,8 @@ import { useRouter } from 'next/navigation';
import * as Y from 'yjs';
import { Box, Text } from '@/components';
import { toBase64, useDocStore } from '@/features/docs/doc-editor';
import { Doc, useUpdateDoc } from '@/features/docs/doc-management';
import { toBase64 } from '@/features/docs/doc-editor';
import { Doc, useDocStore, useUpdateDoc } from '@/features/docs/doc-management';
import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions';
import { Versions } from '../types';

View File

@@ -112,7 +112,7 @@ export const InvitationItem = ({
}}
/>
</Box>
{doc.abilities.manage_accesses && (
{doc.abilities.accesses_manage && (
<Box $margin={isSmallMobile ? 'auto' : ''}>
<Button
color="tertiary-text"

View File

@@ -5,11 +5,13 @@ import { User } from '@/core/auth';
import {
Access,
Doc,
KEY_DOC,
KEY_LIST_DOC,
Role,
} from '@/features/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-list';
import { ContentLanguage } from '@/i18n/types';
import { useBroadcastStore } from '@/stores';
import { OptionType } from '../types';
@@ -53,9 +55,11 @@ export const createDocAccess = async ({
export function useCreateDocAccess() {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Access, APIError, CreateDocAccessParams>({
mutationFn: createDocAccess,
onSuccess: () => {
onSuccess: (_data, variable) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
@@ -65,6 +69,9 @@ export function useCreateDocAccess() {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.docId}`);
},
});
}

View File

@@ -170,14 +170,14 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
doc={doc}
setSelectedUsers={setSelectedUsers}
selectedUsers={selectedUsers}
disabled={isPending || !doc.abilities.manage_accesses}
disabled={isPending || !doc.abilities.accesses_manage}
/>
</Box>
<Box $css="flex: auto;">
<ChooseRole
key={resetKey}
currentRole={currentRole}
disabled={isPending || !doc.abilities.manage_accesses}
disabled={isPending || !doc.abilities.accesses_manage}
setRole={setSelectedRole}
/>
</Box>
@@ -189,7 +189,7 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
!selectedUsers.length ||
isPending ||
!selectedRole ||
!doc.abilities.manage_accesses
!doc.abilities.accesses_manage
}
onClick={() => void handleValidate()}
style={{ height: '100%', maxHeight: '55px' }}

View File

@@ -7,6 +7,7 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_DOC, KEY_LIST_DOC } from '@/features/docs/doc-management';
import { KEY_LIST_USER } from '@/features/docs/members/members-add';
import { useBroadcastStore } from '@/stores';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
@@ -39,6 +40,8 @@ type UseDeleteDocAccessOptions = UseMutationOptions<
export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<void, APIError, DeleteDocAccessProps>({
mutationFn: deleteDocAccess,
...options,
@@ -49,6 +52,10 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variables.docId}`);
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});

View File

@@ -11,6 +11,7 @@ import {
KEY_LIST_DOC,
Role,
} from '@/features/docs/doc-management';
import { useBroadcastStore } from '@/stores';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
@@ -49,6 +50,8 @@ type UseUpdateDocAccessOptions = UseMutationOptions<
export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Access, APIError, UpdateDocAccessProps>({
mutationFn: updateDocAccess,
...options,
@@ -59,6 +62,10 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variables.docId}`);
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});

View File

@@ -61,7 +61,7 @@ export const MemberItem = ({
});
const isNotAllowed =
isOtherOwner || isLastOwner || !doc.abilities.manage_accesses;
isOtherOwner || isLastOwner || !doc.abilities.accesses_manage;
if (!access.user) {
return (
@@ -84,9 +84,10 @@ export const MemberItem = ({
$css={`flex: ${isSmallMobile ? '100%' : '70%'};`}
>
<IconBG iconName="account_circle" $size="2rem" />
<Text $justify="center" $css="flex:1;">
{access.user.email}
</Text>
<Box $justify="center" $css="flex:1;">
{access.user.full_name && <Text>{access.user.full_name}</Text>}
<Text>{access.user.email}</Text>
</Box>
<Box
$direction="row"
$gap="1rem"
@@ -111,7 +112,7 @@ export const MemberItem = ({
}}
/>
</Box>
{doc.abilities.manage_accesses && (
{doc.abilities.accesses_manage && (
<Box $margin={isSmallMobile ? 'auto' : ''}>
<Button
color="tertiary-text"
@@ -135,7 +136,7 @@ export const MemberItem = ({
<TextErrors causes={errorUpdate?.cause || errorDelete?.cause} />
</Box>
)}
{(isLastOwner || isOtherOwner) && doc.abilities.manage_accesses && (
{(isLastOwner || isOtherOwner) && doc.abilities.accesses_manage && (
<Box $margin={{ top: 'tiny' }}>
<Alert
canClose={false}

View File

@@ -1,7 +1,11 @@
import { WorkboxPlugin } from 'workbox-core';
import { Doc, DocsResponse } from '@/features/docs/doc-management';
import { LinkReach, Role } from '@/features/docs/doc-management/types';
import {
LinkReach,
LinkRole,
Role,
} from '@/features/docs/doc-management/types';
import { DBRequest, DocsDB } from './DocsDB';
import { RequestSerializer } from './RequestSerializer';
@@ -190,16 +194,17 @@ export class ApiPlugin implements WorkboxPlugin {
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
abilities: {
accesses_manage: true,
accesses_view: true,
attachment_upload: true,
destroy: true,
link_configuration: true,
partial_update: true,
retrieve: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
attachment_upload: true,
},
accesses: [
{
@@ -209,6 +214,8 @@ export class ApiPlugin implements WorkboxPlugin {
user: {
id: 'dummy-id',
email: 'dummy-email',
full_name: 'dummy-full-name',
short_name: 'dummy-short-name',
},
abilities: {
destroy: false,
@@ -220,7 +227,7 @@ export class ApiPlugin implements WorkboxPlugin {
},
],
link_reach: LinkReach.RESTRICTED,
link_role: 'reader',
link_role: LinkRole.READER,
};
await DocsDB.cacheResponse(

View File

@@ -1,55 +0,0 @@
import { LANGUAGE_LOCAL_STORAGE } from '../conf';
import { getLanguage, splitLocaleCode } from '../utils';
describe('i18n utils', () => {
afterEach(() => {
localStorage.removeItem(LANGUAGE_LOCAL_STORAGE);
});
it('checks language code is correctly splitted', () => {
expect(splitLocaleCode('fr_FR')).toEqual({ language: 'fr', region: 'FR' });
expect(splitLocaleCode('en_US')).toEqual({ language: 'en', region: 'US' });
expect(splitLocaleCode('en')).toEqual({
language: 'en',
region: undefined,
});
expect(splitLocaleCode('fr-FR')).toEqual({ language: 'fr', region: 'FR' });
expect(splitLocaleCode('en-US')).toEqual({ language: 'en', region: 'US' });
});
it('checks that we get expected language from local storage', () => {
localStorage.setItem(LANGUAGE_LOCAL_STORAGE, 'fr_FR');
expect(getLanguage()).toEqual('fr');
localStorage.removeItem(LANGUAGE_LOCAL_STORAGE);
localStorage.setItem(LANGUAGE_LOCAL_STORAGE, 'en_FR');
expect(getLanguage()).toEqual('en');
localStorage.setItem(LANGUAGE_LOCAL_STORAGE, 'xx_XX');
expect(getLanguage()).toEqual('fr');
});
it('checks that we get expected language from browser', () => {
Object.defineProperty(navigator, 'language', {
value: 'fr',
writable: false,
configurable: true,
});
expect(getLanguage()).toEqual('fr');
Object.defineProperty(navigator, 'language', {
value: 'en',
writable: false,
configurable: true,
});
expect(getLanguage()).toEqual('en');
Object.defineProperty(navigator, 'language', {
value: 'xx',
writable: false,
configurable: true,
});
expect(getLanguage()).toEqual('fr');
});
});

View File

@@ -1,6 +1,7 @@
export const LANGUAGES_ALLOWED: { [key: string]: string } = {
en: 'English',
fr: 'Français',
de: 'Deutsch',
};
export const LANGUAGE_LOCAL_STORAGE = 'impress-language';
export const BASE_LANGUAGE = 'fr';
export const LANGUAGE_COOKIE_NAME = 'docs_language';
export const BASE_LANGUAGE = 'en';

View File

@@ -1,30 +1,32 @@
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import { LANGUAGES_ALLOWED, LANGUAGE_LOCAL_STORAGE } from './conf';
import { BASE_LANGUAGE, LANGUAGES_ALLOWED, LANGUAGE_COOKIE_NAME } from './conf';
import resources from './translations.json';
import { getLanguage } from './utils';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
lng: getLanguage(),
fallbackLng: BASE_LANGUAGE,
supportedLngs: Object.keys(LANGUAGES_ALLOWED),
detection: {
order: ['cookie', 'navigator'], // detection order
caches: ['cookie'], // Use cookies to store the language preference
lookupCookie: LANGUAGE_COOKIE_NAME,
cookieMinutes: 525600, // Expires after one year
},
interpolation: {
escapeValue: false,
},
preload: Object.keys(LANGUAGES_ALLOWED),
nsSeparator: '||',
nsSeparator: false,
keySeparator: false,
})
.catch(() => {
throw new Error('i18n initialization failed');
});
// Save language in local storage
i18n.on('languageChanged', (lng) => {
if (typeof window !== 'undefined') {
localStorage.setItem(LANGUAGE_LOCAL_STORAGE, lng);
}
});
export default i18n;

View File

@@ -1,4 +1,118 @@
{
"de": {
"translation": {
"\"{{email}}\" is already invited to the document.": "\"{{email}}\" ist bereits zum Dokument eingeladen.",
"Accessibility": "Barrierefreiheit",
"Accessibility statement": "Erklärung zur Barrierefreiheit",
"Address:": "Anschrift:",
"Administrator": "Administrator",
"Anyone on the internet with the link can view": "Für jeden im Internet mit diesem Link sichtbar",
"Are you sure you want to delete the document \"{{title}}\"?": "Sind Sie sicher, dass Sie das Dokument \"{{title}}\" löschen möchten?",
"Back to home page": "Zurück zur Startseite",
"Back to top": "Zurück nach oben",
"Can't load this page, please check your internet connection.": "Diese Seite kann nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung.",
"Cancel": "Abbrechen",
"Choose a role": "Wählen Sie eine Rolle",
"Close the modal": "Pop up schliessen",
"Close the panel": "Fenster schließen",
"Compliance status": "Konformitätsstatus",
"Confirm deletion": "Löschung bestätigen",
"Content modal to delete document": "Inhalts-Modal zum Löschen des Dokuments",
"Content modal to export the document": "Inhalte zum Exportieren des Dokuments",
"Copy link": "Link kopieren",
"Create a new document": "Neues Dokument erstellen",
"Created at": "Erstellt am",
"Current version": "Aktuelle Version",
"Delete document": "Dokument löschen",
"Delete the document": "Dokument löschen",
"Deleting the document \"{{title}}\"": "Lösche das Dokument \"{{title}}\"",
"Doc visibility card": "Dokumenten-Sichtbarkeitskarte",
"Docs": "Docs",
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Pages: Ihr neuer Begleiter für eine effiziente, intuitive und sichere Zusammenarbeit bei Dokumenten.",
"Document icon": "Dokumentensymbol",
"Document name": "Dokumentenname",
"Document panel": "Dokumenten-Panel",
"Document title updated successfully": "Titel des Dokuments erfolgreich aktualisiert",
"Documents": "Dokumente",
"Docx": "Docx",
"Download": "Herunterladen",
"E-mail:": "E-Mail:",
"Editor": "Editor",
"Export": "Exportieren",
"Export your document, it will be inserted in the selected template.": "Exportieren Sie Ihr Dokument, es wird in die gewählte Vorlage eingefügt.",
"Failed to add the member in the document.": "Fehler beim Hinzufügen des Mitglieds zum Dokument.",
"Failed to copy link": "Link konnte nicht kopiert werden",
"Failed to create the invitation for {{email}}.": "Fehler beim Erstellen der Einladung für {{email}}.",
"Find a member to add to the document": "Suchen Sie ein Mitglied, das dem Dokument hinzugefügt werden soll",
"Go to bottom": "Gehe nach unten",
"If a member is editing, his works can be lost.": "Wenn ein Mitglied editiert, können seine Änderungen verloren gehen.",
"Improvement and contact": "Verbesserungen und Kontakt",
"Invitation sent to {{email}}.": "Einladung an {{email}} gesendet.",
"Invite new members to {{title}}": "Neue Mitglieder zu {{title}} einladen",
"Invited": "Eingeladen",
"It is the card information about the document.": "Es handelt sich um die Karteninformationen zum Dokument.",
"It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Es scheint, dass die von Ihnen gesuchte Seite nicht existiert oder nicht korrekt angezeigt werden kann.",
"Language": "Sprache",
"Legal Notice": "Impressum",
"Legal notice": "Impressum",
"Link Copied !": "Link kopiert!",
"Login": "Anmelden",
"Logout": "Abmelden",
"Members": "Mitglieder",
"No editor found": "Kein Editor gefunden",
"Offline ?!": "Offline?!",
"Only for people with access": "Nur für Personen mit Zugriff",
"Open the document options": "Öffnen Sie die Dokumentoptionen",
"Open the panel": "Panel öffnen",
"Open the version options": "Öffnen Sie die Versionsoptionen",
"Ouch !": "Autsch!",
"Owner": "Besitzer",
"Owners:": "Besitzer:",
"PDF": "PDF",
"Personal data and cookies": "Personenbezogene Daten und Cookies",
"Public": "Öffentlich",
"Read only, you cannot edit document versions.": "Nur lesen: Sie können Dokumentenversionen nicht bearbeiten.",
"Read only, you cannot edit this document.": "Nur lesen: Sie können dieses Dokument nicht bearbeiten.",
"Reader": "Leser",
"Rename": "Umbenennen",
"Restore": "Wiederherstellen",
"Restore the version": "Version wiederherstellen",
"Restore this version": "Version wiederherstellen",
"Restore this version?": "Diese Version wiederherstellen?",
"Role": "Rolle",
"Search by email": "Nach E-Mail suchen",
"Share": "Teilen",
"Share modal": "Teilen-Modal",
"Something bad happens, please retry.": "Etwas ist schiefgelaufen, bitte versuchen Sie es erneut.",
"Table of content": "Inhaltsverzeichnis",
"Table of contents": "Inhaltsverzeichnis",
"Template": "Vorlage",
"The document has been deleted.": "Das Dokument wurde gelöscht.",
"The invitation has been removed.": "Die Einladung wurde zurückgenommen.",
"The member has been removed from the document": "Das Mitglied wurde aus dem Dokument entfernt",
"The role has been updated": "Die Rolle wurde aktualisiert",
"The role has been updated.": "Die Rolle wurde aktualisiert.",
"This accessibility statement applies to the site hosted on": "Diese Erklärung zur Barrierefreiheit gilt für die gehostete Seite",
"This site does not display a cookie consent banner, why?": "",
"Unless otherwise stated, all content on this site is under": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
"Untitled document": "Unbenanntes Dokument",
"Updated at": "Aktualisiert am",
"User {{email}} added to the document.": "Benutzer {{email}} wurde dem Dokument hinzugefügt.",
"Validate": "Bestätigen",
"Version history": "Versionsverlauf",
"Version restored successfully": "Version erfolgreich wiederhergestellt",
"Versions": "Versionen",
"We didn't find a mail matching, try to be more accurate": "Wir haben keine übereinstimmende E-Mail gefunden, versuchen Sie genauer zu sein",
"We try to respond within 2 working days.": "Wir versuchen, innerhalb von 2 Arbeitstagen zu antworten.",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Sie sind der einzige Besitzer dieser Gruppe. Machen Sie ein anderes Mitglied zum Gruppenbesitzer, bevor Sie Ihre eigene Rolle ändern oder aus Ihrem Dokument entfernen können.",
"You cannot update the role or remove other owner.": "Sie können die Rolle nicht aktualisieren oder einen anderen Besitzer entfernen.",
"You don't have any document yet.": "Sie haben noch kein Dokument.",
"Your current document will revert to this version.": "Ihr aktuelles Dokument wird auf diese Version zurückgesetzt.",
"Your role": "Ihre Rolle",
"Your role:": "Ihre Rolle:",
"Your {{format}} was downloaded succesfully": "Ihr {{format}} wurde erfolgreich heruntergeladen"
}
},
"en": { "translation": {} },
"fr": {
"translation": {
@@ -12,8 +126,10 @@
"Administrator": "Administrateur",
"Anyone on the internet with the link can view": "Les personnes disposant du lien peuvent y accéder",
"Are you sure you want to delete the document \"{{title}}\"?": "Êtes-vous sûr de vouloir supprimer le document \"{{title}}\" ?",
"Authenticated": "Authentifié",
"Back to home page": "Retour à l'accueil",
"Back to top": "Retour en haut",
"Can read and edit": "Peut lire et éditer",
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
"Cancel": "Annuler",
"Choose a role": "Choisissez un rôle",
@@ -23,6 +139,7 @@
"Confirm deletion": "Confirmer la suppression",
"Content modal to delete document": "Contenu modal pour supprimer le document",
"Content modal to export the document": "Contenu modal pour exporter le document",
"Convert Markdown": "Convertir le Markdown",
"Cookies placed": "Cookies déposés",
"Copied to clipboard": "Copié dans le presse-papier",
"Copy as {{format}}": "Copier en {{format}}",
@@ -61,6 +178,7 @@
"Find a member to add to the document": "Trouver un membre à ajouter au document",
"French Interministerial Directorate for Digital Affairs (DINUM), 20 avenue de Ségur 75007 Paris.": "Direction interministérielle des affaires numériques (DINUM), 20 avenue de Segur 75007 Paris.",
"Go to bottom": "Aller en bas",
"How people can interact with the document": "Comment les gens peuvent interagir avec le document",
"If a member is editing, his works can be lost.": "Si un membre est en train d'éditer, ses travaux peuvent être perdus.",
"If you are unable to access a content or a service, you can contact the person responsible for https://lasuite.numerique.gouv.fr to be directed to an accessible alternative or to obtain the content in another form.": "Si vous ne pouvez pas accéder à un contenu ou à un service, vous pouvez contacter la personne responsable de https://lasuite. umerique.gouv.fr pour être dirigé vers une alternative accessible ou pour obtenir le contenu sous une autre forme.",
"Illustration:": "Illustration :",
@@ -85,6 +203,7 @@
"No editor found": "Pas d'éditeur trouvé",
"Nothing exceptional, no special privileges related to a .gouv.fr.": "Rien d'exceptionnel, pas de privilèges spéciaux liés à un .gouv.fr.",
"Offline ?!": "Hors-ligne ?!",
"Only for authenticated users": "Uniquement pour les utilisateurs authentifiés",
"Only for people with access": "Seulement pour les personnes avec accès",
"Open the document options": "Ouvrir les options du document",
"Open the header menu": "Ouvrir le menu d'en-tête",
@@ -98,6 +217,7 @@
"Public": "Public",
"Publication Director": "Directeur de la publication",
"Publisher": "Éditeur",
"Read only": "Lecture seule",
"Read only, you cannot edit document versions.": "En lecture seule, vous ne pouvez pas éditer les versions du document.",
"Read only, you cannot edit this document.": "En lecture seule, vous ne pouvez pas éditer ce document.",
"Reader": "Lecteur",
@@ -108,6 +228,7 @@
"Restore the version": "Restaurer la version",
"Restore this version": "Restaurer cette version",
"Restore this version?": "Restaurer cette version?",
"Restricted": "Restreint",
"Role": "Rôle",
"Search by email": "Recherche par email",
"Send a letter by post (free of charge, no stamp needed):": "Envoyer un courrier par la poste (gratuit, ne pas mettre de timbre):",
@@ -120,7 +241,7 @@
"Table of contents": "Table des matières",
"Template": "Template",
"The document has been deleted.": "Le document a bien été supprimé.",
"The document visiblitity has been updated.": "La visibilité du document a été mise à jour.",
"The document visibility has been updated.": "La visibilité du document a été mise à jour.",
"The invitation has been removed.": "L'invitation a été supprimée.",
"The member has been removed from the document": "Le membre a été retiré du document",
"The role has been updated": "Le rôle a été mis à jour",
@@ -142,6 +263,7 @@
"Version history": "Historique des versions",
"Version restored successfully": "Version restaurée avec succès",
"Versions": "Versions",
"Visibility": "Visibilité",
"We didn't find a mail matching, try to be more accurate": "Nous n'avons pas trouvé de correspondance par mail, essayez d'être plus précis",
"We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Nous nous conformons simplement à la loi, qui stipule que certains outils de mesure daudience, correctement configurés pour respecter la vie privée, sont exemptés de toute autorisation préalable.",
"We try to respond within 2 working days.": "Nous essayons de répondre dans les 2 jours ouvrables.",

View File

@@ -1,28 +0,0 @@
import {
BASE_LANGUAGE,
LANGUAGES_ALLOWED,
LANGUAGE_LOCAL_STORAGE,
} from './conf';
export const splitLocaleCode = (language: string) => {
const locale = language.split(/[-_]/);
return {
language: locale[0],
region: locale.length === 2 ? locale[1] : undefined,
};
};
export const getLanguage = () => {
if (typeof window === 'undefined') {
return BASE_LANGUAGE;
}
const languageStore =
localStorage.getItem(LANGUAGE_LOCAL_STORAGE) || navigator?.language;
const language = splitLocaleCode(languageStore).language;
return Object.keys(LANGUAGES_ALLOWED).includes(language)
? language
: BASE_LANGUAGE;
};

View File

@@ -1,4 +1,6 @@
import { Loader } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import Head from 'next/head';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
@@ -6,9 +8,10 @@ import { useEffect, useState } from 'react';
import { Box, Text } from '@/components';
import { TextErrors } from '@/components/TextErrors';
import { useAuthStore } from '@/core/auth';
import { DocEditor, useDocStore } from '@/features/docs';
import { useDoc } from '@/features/docs/doc-management';
import { DocEditor } from '@/features/docs/doc-editor';
import { KEY_DOC, useDoc, useDocStore } from '@/features/docs/doc-management';
import { MainLayout } from '@/layouts';
import { useBroadcastStore } from '@/stores';
import { NextPageWithLayout } from '@/types/next';
export function DocLayout() {
@@ -21,9 +24,14 @@ export function DocLayout() {
}
return (
<MainLayout withoutFooter>
<DocPage id={id} />
</MainLayout>
<>
<Head>
<meta name="robots" content="noindex" />
</Head>
<MainLayout withoutFooter>
<DocPage id={id} />
</MainLayout>
</>
);
}
@@ -35,9 +43,11 @@ const DocPage = ({ id }: DocProps) => {
const { login } = useAuthStore();
const { data: docQuery, isError, error } = useDoc({ id });
const [doc, setDoc] = useState(docQuery);
const { setCurrentDoc } = useDocStore();
const { setCurrentDoc, createProvider, docsStore } = useDocStore();
const { setBroadcastProvider, addTask } = useBroadcastStore();
const queryClient = useQueryClient();
const navigate = useNavigate();
const provider = docsStore?.[id]?.provider;
useEffect(() => {
if (doc?.title) {
@@ -60,6 +70,35 @@ const DocPage = ({ id }: DocProps) => {
};
}, [docQuery, setCurrentDoc]);
useEffect(() => {
if (!doc?.id) {
return;
}
let newProvider = provider;
if (!provider || provider.document.guid !== doc.id) {
newProvider = createProvider(doc.id, doc.content);
}
setBroadcastProvider(newProvider);
}, [createProvider, doc, provider, setBroadcastProvider]);
/**
* We add a broadcast task to reset the query cache
* when the document visibility changes.
*/
useEffect(() => {
if (!doc?.id) {
return;
}
addTask(`${KEY_DOC}-${doc.id}`, () => {
void queryClient.resetQueries({
queryKey: [KEY_DOC, { id: doc.id }],
});
});
}, [addTask, doc?.id, queryClient]);
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);

View File

@@ -1 +1,2 @@
export * from './useBroadcastStore';
export * from './useResponsiveStore';

View File

@@ -0,0 +1,54 @@
import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { create } from 'zustand';
interface BroadcastState {
addTask: (taskLabel: string, action: () => void) => void;
broadcast: (taskLabel: string) => void;
getBroadcastProvider: () => HocuspocusProvider | undefined;
provider?: HocuspocusProvider;
setBroadcastProvider: (provider: HocuspocusProvider) => void;
tasks: { [taskLabel: string]: Y.Array<string> };
}
export const useBroadcastStore = create<BroadcastState>((set, get) => ({
provider: undefined,
tasks: {},
setBroadcastProvider: (provider) => set({ provider }),
getBroadcastProvider: () => {
const provider = get().provider;
if (!provider) {
console.warn('Provider is not defined');
return;
}
return provider;
},
addTask: (taskLabel, action) => {
const taskExistAlready = get().tasks[taskLabel];
const provider = get().getBroadcastProvider();
if (taskExistAlready || !provider) {
return;
}
const task = provider.document.getArray<string>(taskLabel);
task.observe(() => {
action();
});
set((state) => ({
tasks: {
...state.tasks,
[taskLabel]: task,
},
}));
},
broadcast: (taskLabel) => {
const task = get().tasks[taskLabel];
if (!task) {
console.warn(`Task ${taskLabel} is not defined`);
return;
}
task.push([`broadcast: ${taskLabel}`]);
},
}));

View File

@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "1.6.0",
"version": "1.7.0",
"private": true,
"workspaces": {
"packages": [
@@ -25,13 +25,13 @@
"i18n:test": "yarn I18N run test"
},
"resolutions": {
"@blocknote/core": "0.17.0",
"@blocknote/mantine": "0.17.0",
"@blocknote/react": "0.17.0",
"@types/node": "20.16.12",
"@blocknote/core": "0.17.1",
"@blocknote/mantine": "0.17.1",
"@blocknote/react": "0.17.1",
"@types/node": "20.16.13",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.9.0",
"@typescript-eslint/parser": "8.9.0",
"@typescript-eslint/eslint-plugin": "8.10.0",
"@typescript-eslint/parser": "8.10.0",
"cross-env": "7.0.3",
"eslint": "8.57.0",
"react": "18.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "eslint-config-impress",
"version": "1.6.0",
"version": "1.7.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
@@ -15,11 +15,11 @@
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest": "28.8.3",
"eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-playwright": "1.7.0",
"eslint-plugin-jsx-a11y": "6.10.1",
"eslint-plugin-playwright": "1.8.0",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "7.37.1",
"eslint-plugin-testing-library": "6.3.0",
"eslint-plugin-testing-library": "6.4.0",
"prettier": "3.3.3"
}
}

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