Compare commits

...

14 Commits

Author SHA1 Message Date
Anthony LC
14b2adedfb 🔖(minor) release 2.6.0
Added:
- 📝(doc) add publiccode.yml

Changed
- 🚸(frontend) ctrl+k modal not when editor is focused

Fixed:
- 🐛(back) allow only images to be used with
  the cors-proxy
- 🐛(backend) stop returning inactive users
  on the list endpoint
- 🔒️(backend) require at least 5 characters
  to search for users
- 🔒️(back) throttle user list endpoint
- 🔒️(back) remove pagination and limit to
   5 for user list endpoint
2025-03-21 17:07:26 +01:00
Anthony LC
a7edb382a7 🩹(frontent) change selector to block cmd+k
Multiple ctrl+k could open the search modal, we
change the selector, now if the toolbar is displayed
we don't open the search modal.
2025-03-21 17:07:26 +01:00
Anthony LC
fb5400c26b ️(frontend) search users with at least 5 characters
We now only search for users when the query
is at least 5 characters long.
2025-03-21 15:44:09 +01:00
Manuel Raynaud
8473facbee 🔒️(back) throttle user list endpoint
The user list endpoint is throttle to avoid users discovery. The
throttle is set to 500 requests per day. This can be changed using the
settings API_USERS_LIST_THROTTLE_RATE.
2025-03-21 15:44:09 +01:00
Anthony LC
5db446e8a8 🏷️(frontend) adapt type for user search
The response from the user request is now an
array of users, we don't paginate anymore.
We adapt the types to reflect this.
2025-03-21 15:44:09 +01:00
Manuel Raynaud
34dfb3fd66 🔒️(back) remove pagination and limit to 5 for user list endpoint
The user list endpoint does not use anymore a pagination, the results is
directly return in a list and the max results returned is limited to 5.
In order to modify this limit the settings API_USERS_LIST_LIMIT is
used.
2025-03-21 15:44:09 +01:00
Samuel Paccoud - DINUM
f9a91eda2d 🐛(backend) stop returning inactive users on the list endpoint
inactive users should not be returned as we don't want users to be
able to share new documents with them.
2025-03-21 15:44:09 +01:00
Samuel Paccoud - DINUM
eba926dea4 🔒️(backend) require at least 5 characters to search for users
Listing users is made a little to easy for authenticated users.
2025-03-21 15:44:09 +01:00
Anthony LC
3839a2e8b1 💄(frontend) improve contrast of Beta icon
The colors of the Beta icon were not contrasted
enough. This was posing an accessibility issue.
We now use a more contrasted color.
2025-03-21 09:22:42 +01:00
Anthony LC
a88d62e07d 🌐(frontend) make Docs title translatable
The title of the docs page was not translatable.
We now use the `t` function to translate the title.
2025-03-21 09:22:42 +01:00
Paul Mustière
b61a7a4961 📝(docs) fix typo
Correct language to not be past tense
2025-03-21 06:38:27 +01:00
Anthony LC
20d32ecc4e 🚸(frontend) ctrl+k modal not when editor is focused
ctrl+k interaction was as well used in the editor.
So if the user has a focus on the editor, we don't
open the searchmodal.
2025-03-20 17:43:32 +01:00
Manuel Raynaud
313acf4f78 🐛(back) allow only images to be used with the cors-proxy
The cors-proxy endpoint allowed to use every type of files and to
execute it in the browser. We limit the scope only to images and
Content-Security-Policy and Content-Disposition headers are also added
to not allow script execution that can be present in a SVG file.
2025-03-20 16:10:47 +01:00
Bastien
3a6105cc7e 📝(doc) add publiccode.yml (#770)
publiccode.yml is a standard for describing Free Software projects,
similar to other initiatives such as https://codemeta.github.io.

It is particularly suitable for describing projects funded by public
administrations. See https://github.com/publiccodeyml/publiccode.yml
2025-03-19 21:28:32 +01:00
26 changed files with 315 additions and 50 deletions

View File

@@ -8,6 +8,25 @@ and this project adheres to
## [Unreleased]
## [2.6.0] - 2025-03-21
## Added
- 📝(doc) add publiccode.yml #770
## Changed
- 🚸(frontend) ctrl+k modal not when editor is focused #712
## Fixed
- 🐛(back) allow only images to be used with the cors-proxy #781
- 🐛(backend) stop returning inactive users on the list endpoint #636
- 🔒️(backend) require at least 5 characters to search for users #636
- 🔒️(back) throttle user list endpoint #636
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
## [2.5.0] - 2025-03-18
## Added
@@ -468,7 +487,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.5.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.6.0...main
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0

27
publiccode.yml Normal file
View File

@@ -0,0 +1,27 @@
publiccodeYmlVersion: "2.4.0"
name: Docs
url: https://github.com/suitenumerique/docs
landingURL: https://github.com/suitenumerique/docs
creationDate: 2023-12-10
logo: https://raw.githubusercontent.com/suitenumerique/docs/main/docs/assets/docs-logo.png
usedBy:
- Direction interministériel du numérique (DINUM)
fundedBy:
- name: Direction interministériel du numérique (DINUM)
url: https://www.numerique.gouv.fr
roadmap: "https://github.com/orgs/suitenumerique/projects/2/views/1"
softwareType: "standalone/other"
description:
en:
shortDescription: "The open source document editor where your notes can become knowledge through live collaboration"
fr:
shortDescription: "L'éditeur de documents open source où vos notes peuvent devenir des connaissances grâce à la collaboration en direct."
legal:
license: MIT
maintenance:
type: internal
contacts:
- name: "Virgile Deville"
email: "virgile.deville@numerique.gouv.fr"
- name: "samuel.paccoud"
email: "samuel.paccoud@numerique.gouv.fr"

View File

@@ -24,6 +24,7 @@ from botocore.exceptions import ClientError
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import UserRateThrottle
from core import authentication, enums, models
from core.services.ai_services import AIService
@@ -135,14 +136,35 @@ class Pagination(drf.pagination.PageNumberPagination):
page_size_query_param = "page_size"
class UserListThrottleBurst(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_burst"
class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"
class UserViewSet(
drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin
):
"""User ViewSet"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all()
queryset = models.User.objects.filter(is_active=True)
serializer_class = serializers.UserSerializer
pagination_class = None
throttle_classes = []
def get_throttles(self):
self.throttle_classes = []
if self.action == "list":
self.throttle_classes = [UserListThrottleBurst, UserListThrottleSustained]
return super().get_throttles()
def get_queryset(self):
"""
@@ -157,11 +179,11 @@ class UserViewSet(
return queryset
# Exclude all users already in the given document
if document_id := self.request.GET.get("document_id", ""):
if document_id := self.request.query_params.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
if not (query := self.request.GET.get("q", "")):
return queryset
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
# For emails, match emails by Levenstein distance to prevent typing errors
if "@" in query:
@@ -170,7 +192,7 @@ class UserViewSet(
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
)
.filter(distance__lte=3)
.order_by("distance", "email")
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
)
# Use trigram similarity for non-email-like queries
@@ -180,7 +202,7 @@ class UserViewSet(
queryset.filter(email__trigram_word_similar=query)
.annotate(similarity=TrigramSimilarity("email", query))
.filter(similarity__gt=0.2)
.order_by("-similarity", "email")
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
)
@drf.decorators.action(
@@ -1271,13 +1293,21 @@ class DocumentViewSet(
},
timeout=10,
)
content_type = response.headers.get("Content-Type", "")
if not content_type.startswith("image/"):
return drf.response.Response(
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
)
# Use StreamingHttpResponse with the response's iter_content to properly stream the data
proxy_response = StreamingHttpResponse(
streaming_content=response.iter_content(chunk_size=8192),
content_type=response.headers.get(
"Content-Type", "application/octet-stream"
),
content_type=content_type,
headers={
"Content-Disposition": "attachment;",
"Content-Security-Policy": "default-src 'none'; img-src 'none' data:;",
},
status=response.status_code,
)

View File

@@ -1,6 +1,7 @@
"""Test on the CORS proxy API for documents."""
import pytest
import responses
from rest_framework.test import APIClient
from core import factories
@@ -8,17 +9,24 @@ from core import factories
pytestmark = pytest.mark.django_db
@responses.activate
def test_api_docs_cors_proxy_valid_url():
"""Test the CORS proxy API for documents with a valid URL."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png"
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png"
assert response.headers["Content-Disposition"] == "attachment;"
assert (
response.headers["Content-Security-Policy"]
== "default-src 'none'; img-src 'none' data:;"
)
assert response.streaming_content
@@ -32,12 +40,14 @@ def test_api_docs_cors_proxy_without_url_query_string():
assert response.json() == {"detail": "Missing 'url' query parameter"}
@responses.activate
def test_api_docs_cors_proxy_anonymous_document_not_public():
"""Test the CORS proxy API for documents with an anonymous user and a non-public document."""
document = factories.DocumentFactory(link_reach="authenticated")
client = APIClient()
url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png"
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
@@ -47,6 +57,7 @@ def test_api_docs_cors_proxy_anonymous_document_not_public():
}
@responses.activate
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
"""
Test the CORS proxy API for documents with an authenticated user accessing a protected
@@ -58,15 +69,22 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
client = APIClient()
client.force_login(user)
url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png"
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png"
assert response.headers["Content-Disposition"] == "attachment;"
assert (
response.headers["Content-Security-Policy"]
== "default-src 'none'; img-src 'none' data:;"
)
assert response.streaming_content
@responses.activate
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
"""
Test the CORS proxy API for documents with an authenticated user not accessing a restricted
@@ -78,7 +96,8 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
client = APIClient()
client.force_login(user)
url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png"
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
@@ -86,3 +105,17 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@responses.activate
def test_api_docs_cors_proxy_unsupported_media_type():
"""Test the CORS proxy API for documents with an unsupported media type."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=b"", status=200, content_type="text/html")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 415

View File

@@ -24,7 +24,7 @@ def test_api_users_list_anonymous():
def test_api_users_list_authenticated():
"""
Authenticated users should be able to list users.
Authenticated users should not be able to list users without a query.
"""
user = factories.UserFactory()
@@ -37,7 +37,7 @@ def test_api_users_list_authenticated():
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert content == []
def test_api_users_list_query_email():
@@ -58,24 +58,76 @@ def test_api_users_list_query_email():
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.cop",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == []
def test_api_users_list_limit(settings):
"""
Authenticated users should be able to list users and the number of results
should be limited to 10.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Use a base name with a length equal 5 to test that the limit is applied
base_name = "alice"
for i in range(15):
factories.UserFactory(email=f"{base_name}.{i}@example.com")
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert len(response.json()) == 5
# if the limit is changed, all users should be returned
settings.API_USERS_LIST_LIMIT = 100
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert len(response.json()) == 15
def test_api_users_list_throttling_authenticated(settings):
"""
Authenticated users should be throttled.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "3/minute"
for _i in range(3):
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 429
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by Levenstein distance."""
user = factories.UserFactory()
@@ -94,13 +146,13 @@ def test_api_users_list_query_email_matching():
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)]
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)]
@@ -126,10 +178,50 @@ def test_api_users_list_query_email_exclude_doc_user():
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(nicole_fool.id)]
def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200
assert response.json() == []
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200
assert response.json() == []
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert len(response.json()) == 2
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com", is_active=False)
lennon = factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(lennon.id)]
def test_api_users_retrieve_me_anonymous():
"""Anonymous users should not be allowed to list users."""
factories.UserFactory.create_batch(2)

View File

@@ -337,6 +337,18 @@ class Base(Configuration):
"PAGE_SIZE": 20,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_RATES": {
"user_list_sustained": values.Value(
default="180/hour",
environ_name="API_USERS_LIST_THROTTLE_RATE_SUSTAINED",
environ_prefix=None,
),
"user_list_burst": values.Value(
default="30/minute",
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
environ_prefix=None,
),
},
}
SPECTACULAR_SETTINGS = {
@@ -604,6 +616,12 @@ class Base(Configuration):
},
}
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
default=5,
environ_name="API_USERS_LIST_LIMIT",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "2.5.0"
version = "2.6.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",

View File

@@ -80,11 +80,11 @@ export const addNewMember = async (
page: Page,
index: number,
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
fillText: string = 'user',
fillText: string = 'user ',
) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
response.url().includes(`/users/?q=${fillText}`) &&
response.url().includes(`/users/?q=${encodeURIComponent(fillText)}`) &&
response.status() === 200,
);
@@ -97,7 +97,7 @@ export const addNewMember = async (
// Intercept response
const responseSearchUser = await responsePromiseSearchUser;
const users = (await responseSearchUser.json()).results as {
const users = (await responseSearchUser.json()) as {
email: string;
}[];

View File

@@ -8,9 +8,11 @@ test.beforeEach(async ({ page }) => {
test.describe('Document create member', () => {
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const inputFill = 'user ';
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=user') && response.status() === 200,
response.url().includes(`/users/?q=${encodeURIComponent(inputFill)}`) &&
response.status() === 200,
);
await createDoc(page, 'select-multi-users', browserName, 1);
@@ -22,9 +24,9 @@ test.describe('Document create member', () => {
await expect(inputSearch).toBeVisible();
// Select user 1 and verify tag
await inputSearch.fill('user');
await inputSearch.fill(inputFill);
const response = await responsePromise;
const users = (await response.json()).results as {
const users = (await response.json()) as {
email: string;
full_name?: string | null;
}[];
@@ -45,7 +47,7 @@ test.describe('Document create member', () => {
).toBeVisible();
// Select user 2 and verify tag
await inputSearch.fill('user');
await inputSearch.fill(inputFill);
await quickSearchContent
.getByTestId(`search-user-row-${users[1].email}`)
.click();

View File

@@ -63,4 +63,35 @@ test.describe('Document search', () => {
listSearch.getByRole('option').getByText(doc2Title),
).toBeHidden();
});
test('it checks cmd+k modal search interaction', async ({
page,
browserName,
}) => {
const [doc1Title] = await createDoc(
page,
'Doc seack ctrl k',
browserName,
1,
);
await verifyDocName(page, doc1Title);
await page.keyboard.press('Control+k');
await expect(
page.getByLabel('Search modal').getByText('search'),
).toBeVisible();
await page.keyboard.press('Escape');
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello world');
await editor.getByText('Hello world').dblclick();
await page.keyboard.press('Control+k');
await expect(page.getByRole('textbox', { name: 'Edit URL' })).toBeVisible();
await expect(
page.getByLabel('Search modal').getByText('search'),
).toBeHidden();
});
});

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "2.5.0",
"version": "2.6.0",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -1,6 +1,6 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/docs/doc-management';
import { User } from '@/features/auth';
@@ -9,14 +9,14 @@ export type UsersParams = {
docId: Doc['id'];
};
type UsersResponse = APIList<User>;
type UsersResponse = User[];
export const getUsers = async ({
query,
docId,
}: UsersParams): Promise<UsersResponse> => {
const queriesParams = [];
queriesParams.push(query ? `q=${query}` : '');
queriesParams.push(query ? `q=${encodeURIComponent(query)}` : '');
queriesParams.push(docId ? `document_id=${docId}` : '');
const queryParams = queriesParams.filter(Boolean).join('&');

View File

@@ -58,6 +58,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
const canViewAccesses = doc.abilities.accesses_view;
const showMemberSection = inputValue === '' && selectedUsers.length === 0;
const showFooter = selectedUsers.length === 0 && !inputValue;
const MIN_CHARACTERS_FOR_SEARCH = 4;
const onSelect = (user: User) => {
setSelectedUsers((prev) => [...prev, user]);
@@ -76,7 +77,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
const searchUsersQuery = useUsers(
{ query: userQuery, docId: doc.id },
{
enabled: !!userQuery,
enabled: userQuery?.length > MIN_CHARACTERS_FOR_SEARCH,
queryKey: [KEY_LIST_USER, { query: userQuery }],
},
);
@@ -125,7 +126,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
}, [invitationQuery, t]);
const searchUserData: QuickSearchData<User> = useMemo(() => {
const users = searchUsersQuery.data?.results || [];
const users = searchUsersQuery.data || [];
const isEmail = isValidEmail(userQuery);
const newUser: User = {
id: userQuery,

View File

@@ -8,7 +8,6 @@ export const Title = () => {
const { t } = useTranslation();
const theme = useCunninghamTheme();
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
return (
<Box $direction="row" $align="center" $gap={spacings['2xs']}>
@@ -36,7 +35,8 @@ export const Title = () => {
`}
$width="40px"
$height="16px"
$background={colors['primary-200']}
$background="#ECECFF"
$color="#5958D3"
>
BETA
</Text>

View File

@@ -104,7 +104,7 @@ export function HomeContent() {
</Text>
<Text as="p" $display="inline">
<Trans t={t} i18nKey="home-content-open-source-part2">
You can easily self-hosted Docs (check our installation{' '}
You can easily self-host Docs (check our installation{' '}
<a
href="https://github.com/suitenumerique/docs/tree/main/docs"
target="_blank"

View File

@@ -15,7 +15,15 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
const router = useRouter();
const searchModal = useModal();
const { authenticated } = useAuth();
useCmdK(searchModal.open);
useCmdK(() => {
const isEditorToolbarOpen =
document.getElementsByClassName('bn-formatting-toolbar').length > 0;
if (isEditorToolbarOpen) {
return;
}
searchModal.open();
});
const { togglePanel } = useLeftPanelStore();
const { mutate: createDoc } = useCreateDoc({

View File

@@ -5,6 +5,7 @@ export const useCmdK = (callback: () => void) => {
const down = (e: KeyboardEvent) => {
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
callback();
}
};

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import { DocEditor } from '@/docs/doc-editor';
@@ -64,14 +65,15 @@ const DocPage = ({ id }: DocProps) => {
const queryClient = useQueryClient();
const { replace } = useRouter();
useCollaboration(doc?.id, doc?.content);
const { t } = useTranslation();
useEffect(() => {
if (doc?.title) {
setTimeout(() => {
document.title = `${doc.title} - Docs`;
document.title = `${doc.title} - ${t('Docs')}`;
}, 100);
}
}, [doc?.title]);
}, [doc?.title, t]);
useEffect(() => {
if (!docQuery || isFetching) {

View File

@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "2.5.0",
"version": "2.6.0",
"private": true,
"workspaces": {
"packages": [

View File

@@ -1,6 +1,6 @@
{
"name": "eslint-config-impress",
"version": "2.5.0",
"version": "2.6.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."

View File

@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "2.5.0",
"version": "2.6.0",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:impress",

View File

@@ -1,6 +1,6 @@
{
"name": "server-y-provider",
"version": "2.5.0",
"version": "2.6.0",
"description": "Y.js provider for docs",
"repository": "https://github.com/numerique-gouv/impress",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
environments:
dev:
values:
- version: 2.5.0
- version: 2.6.0
---
repositories:
- name: bitnami

View File

@@ -1,5 +1,5 @@
apiVersion: v2
type: application
name: docs
version: 2.5.0
version: 2.6.0
appVersion: latest

View File

@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "2.5.0",
"version": "2.6.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {