Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06b8a09ec0 | |||
| ecd06560c6 | |||
| e9ab099ce0 | |||
| 67b69d05f7 | |||
| f429eb053a | |||
| ad11b7f554 | |||
| 3d5adad227 |
@@ -8,6 +8,15 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- 🚩 add homepage feature flag #861
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🔒️(back) don't allow an owner to change or delete other owner accesses
|
||||
|
||||
|
||||
## [3.1.0] - 2025-04-07
|
||||
|
||||
## Added
|
||||
|
||||
@@ -48,12 +48,7 @@ Docs is a collaborative text editor designed to address common challenges in kno
|
||||
|
||||
### Test it
|
||||
|
||||
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/)
|
||||
|
||||
```
|
||||
email: test.docs@yopmail.com
|
||||
password: I'd<3ToTestDocs
|
||||
```
|
||||
Test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
|
||||
|
||||
### Run it locally
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
## Decision TLDR;
|
||||
|
||||
We will use Yjs a CRDT-based library for the collaborative editing of the documents.
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
We need to implement a collaborative editing feature for the documents that supports real-time collaboration, offline capabilities, and seamless integration with our Django backend.
|
||||
|
||||
## Considered alternatives
|
||||
|
||||
### ProseMirror
|
||||
|
||||
A robust toolkit for building rich-text editors with collaboration capabilities.
|
||||
|
||||
| Pros | Cons |
|
||||
| --- | --- |
|
||||
| Mature ecosystem | Complex integration with Django |
|
||||
| Rich text editing features | Steeper learning curve |
|
||||
| Used by major companies | More complex to implement offline support |
|
||||
| Large community | |
|
||||
|
||||
### ShareDB
|
||||
|
||||
Real-time database backend based on Operational Transformation.
|
||||
|
||||
| Pros | Cons |
|
||||
| --- | --- |
|
||||
| Battle-tested in production | Complex setup required |
|
||||
| Strong consistency model | Requires specific backend architecture |
|
||||
| Good documentation | Less flexible with different backends |
|
||||
| | Higher latency compared to CRDTs |
|
||||
|
||||
### Convergence
|
||||
|
||||
Complete enterprise solution for real-time collaboration.
|
||||
|
||||
| Pros | Cons |
|
||||
| --- | --- |
|
||||
| Full-featured solution | Commercial licensing |
|
||||
| Built-in presence features | Less community support |
|
||||
| Enterprise support | More expensive |
|
||||
| Good offline support | Overkill for basic needs |
|
||||
|
||||
### CRDT-based Solutions Comparison
|
||||
|
||||
A CRDT-based library specifically designed for real-time collaboration.
|
||||
|
||||
| Category | Pros | Cons |
|
||||
|----------|------|------|
|
||||
| Technical Implementation | • Native real-time collaboration<br>• No central conflict resolution needed<br>• Works well with Django backend<br>• Automatic state synchronization | • Learning curve for CRDT concepts<br>• More complex initial setup<br>• Additional metadata overhead |
|
||||
| User Experience | • Instant local updates<br>• Works offline by default<br>• Low latency<br>• Smooth concurrent editing | • Eventual consistency might cause brief inconsistencies<br>• UI must handle temporary conflicts |
|
||||
| Performance | • Excellent scaling with multiple users<br>• Reduced server load<br>• Efficient network usage<br>• Good memory optimization (especially Yjs) | • Slightly higher memory usage<br>• Initial state sync can be larger |
|
||||
| Development | • No need to build conflict resolution<br>• Simple integration with text editors<br>• Future-proof architecture | • Team needs to learn new concepts<br>• Fewer ready-made solutions<br>• May need to build some features from scratch |
|
||||
| Maintenance | • Less server infrastructure<br>• Simpler deployment<br>• Fewer points of failure | • Debugging can be more complex<br>• State management requires careful handling |
|
||||
| Business Impact | • Better offline support for users<br>• Scales well as user base grows<br>• No licensing costs (with Yjs) | • Initial development time might be longer<br>• Team training required |
|
||||
|
||||
#### Yjs
|
||||
- **Type**: State-based CRDT
|
||||
- **Implementation**: JavaScript/TypeScript
|
||||
- **Features**:
|
||||
- Rich text collaboration
|
||||
- Shared types (Array, Map, XML)
|
||||
- Binary encoding
|
||||
- P2P support
|
||||
- **Performance**: Excellent for text editing
|
||||
- **Memory Usage**: Optimized
|
||||
- **License**: MIT
|
||||
|
||||
#### Automerge
|
||||
- **Type**: Operation-based CRDT
|
||||
- **Implementation**: JavaScript/Rust
|
||||
- **Features**:
|
||||
- JSON-like data structures
|
||||
- Change history
|
||||
- Undo/Redo
|
||||
- Binary format
|
||||
- **Performance**: Good, with Rust backend
|
||||
- **Memory Usage**: Higher than Yjs
|
||||
- **License**: MIT
|
||||
|
||||
#### Legion
|
||||
- **Type**: State-based CRDT
|
||||
- **Implementation**: Rust with JS bindings
|
||||
- **Features**:
|
||||
- High performance
|
||||
- Memory efficient
|
||||
- Binary protocol
|
||||
- **Performance**: Excellent
|
||||
- **Memory Usage**: Very efficient
|
||||
- **License**: Apache 2.0
|
||||
|
||||
#### Diamond Types
|
||||
- **Type**: Operation-based CRDT
|
||||
- **Implementation**: TypeScript
|
||||
- **Features**:
|
||||
- Specialized for text
|
||||
- Small memory footprint
|
||||
- Simple API
|
||||
- **Performance**: Good for text
|
||||
- **Memory Usage**: Efficient
|
||||
- **License**: MIT
|
||||
|
||||
Comparison Table:
|
||||
|
||||
| Feature | Yjs | Automerge | Legion | Diamond Types |
|
||||
|---------|-----|-----------|--------|---------------|
|
||||
| Text Editing | ✅ Excellent | ✅ Good | ⚠️ Basic | ✅ Excellent |
|
||||
| Structured Data | ✅ | ✅ | ✅ | ⚠️ |
|
||||
| Memory Efficiency | ✅ High | ⚠️ Medium | ✅ Very High | ✅ High |
|
||||
| Network Efficiency | ✅ | ⚠️ | ✅ | ✅ |
|
||||
| Maturity | ✅ | ✅ | ⚠️ | ⚠️ |
|
||||
| Community Size | ✅ Large | ✅ Large | ⚠️ Small | ⚠️ Small |
|
||||
| Documentation | ✅ | ✅ | ⚠️ | ⚠️ |
|
||||
| Backend Options | ✅ Many | ✅ Many | ⚠️ Limited | ⚠️ Limited |
|
||||
|
||||
Key Differences:
|
||||
1. **Implementation Approach**:
|
||||
- Yjs: Optimized for text and rich-text editing
|
||||
- Automerge: General-purpose JSON CRDT
|
||||
- Legion: Performance-focused with Rust
|
||||
- Diamond Types: Specialized for text collaboration
|
||||
|
||||
2. **Performance Characteristics**:
|
||||
- Yjs: Best for text editing scenarios
|
||||
- Automerge: Good all-around performance
|
||||
- Legion: Excellent raw performance
|
||||
- Diamond Types: Optimized for text
|
||||
|
||||
3. **Ecosystem Integration**:
|
||||
- Yjs: Wide range of integrations
|
||||
- Automerge: Good JavaScript ecosystem
|
||||
- Legion: Limited but growing
|
||||
- Diamond Types: Focused on text editors
|
||||
|
||||
This analysis reinforces our choice of Yjs for the CRDT-based option as it provides:
|
||||
- Best-in-class text editing performance
|
||||
- Mature ecosystem
|
||||
- Active community
|
||||
- Excellent documentation
|
||||
- Wide range of backend options
|
||||
|
||||
## Decision
|
||||
|
||||
After evaluating the alternatives, we choose Yjs for the following reasons:
|
||||
|
||||
1. **Technical Fit:**
|
||||
- Native CRDT support ensures reliable collaboration
|
||||
- Excellent offline capabilities
|
||||
- Good performance characteristics
|
||||
- Flexible backend integration options
|
||||
|
||||
2. **Project Requirements Match:**
|
||||
- Easy integration with our Django backend
|
||||
- Supports our core collaborative features
|
||||
- Manageable learning curve for the team
|
||||
|
||||
3. **Community & Support:**
|
||||
- Active development
|
||||
- Growing community
|
||||
- Good documentation
|
||||
- Open source with MIT license
|
||||
|
||||
### Comparison of Key Features:
|
||||
|
||||
| Feature | Yjs (CRDT) | ProseMirror | ShareDB | Convergence |
|
||||
|---------|-----|-------------|----------|-------------|
|
||||
| Real-time Collaboration | ✅ | ✅ | ✅ | ✅ |
|
||||
| Offline Support | ✅ | ⚠️ | ⚠️ | ✅ |
|
||||
| Django Integration | Easy | Complex | Complex | Moderate |
|
||||
| Learning Curve | Medium | High | High | Medium |
|
||||
| Cost | Free | Free | Free | Paid |
|
||||
| Community Size | Growing | Large | Medium | Small |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Simplified implementation of real-time collaboration
|
||||
- Good developer experience
|
||||
- Future-proof technology choice
|
||||
- No licensing costs
|
||||
|
||||
### Negative
|
||||
- Team needs to learn CRDT concepts
|
||||
- Newer technology compared to alternatives
|
||||
- May need to build some features available out-of-the-box in other solutions
|
||||
|
||||
### Risks
|
||||
- Community support might not grow as expected
|
||||
- May discover limitations as we scale
|
||||
@@ -0,0 +1,19 @@
|
||||
## Architecture
|
||||
|
||||
### Global system architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
User -- HTTP --> Front("Frontend (NextJS SPA)")
|
||||
Front -- REST API --> Back("Backend (Django)")
|
||||
Front -- WebSocket --> Yserver("Microservice Yjs (Express)") -- WebSocket --> CollaborationServer("Collaboration server (Hocuspocus)") -- REST API <--> Back
|
||||
Front -- OIDC --> Back -- OIDC ---> OIDC("Keycloak / ProConnect")
|
||||
Back -- REST API --> Yserver
|
||||
Back --> DB("Database (PostgreSQL)")
|
||||
Back <--> Celery --> DB
|
||||
Back ----> S3("Minio (S3)")
|
||||
```
|
||||
|
||||
### Architecture decision records
|
||||
|
||||
- [ADR-0001-20250106-use-yjs-for-docs-editing](./adr/ADR-0001-20250106-use-yjs-for-docs-editing.md)
|
||||
@@ -47,6 +47,11 @@ These are the environmental variables you can set for the impress-backend contai
|
||||
| COLLABORATION_API_URL | collaboration api host | |
|
||||
| COLLABORATION_SERVER_SECRET | collaboration api secret | |
|
||||
| COLLABORATION_WS_URL | collaboration websocket url | |
|
||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
|
||||
| FRONTEND_FOOTER_FEATURE_ENABLED | frontend feature flag to display the footer | false |
|
||||
| FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT | Cache duration of the json footer | 86400 |
|
||||
| FRONTEND_URL_JSON_FOOTER | Url with a json to configure the footer | |
|
||||
| FRONTEND_THEME | frontend theme to use | |
|
||||
| POSTHOG_KEY | posthog key for analytics | |
|
||||
| CRISP_WEBSITE_ID | crisp website id for support | |
|
||||
|
||||
@@ -64,5 +64,6 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME=default
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED=True
|
||||
FRONTEND_FOOTER_FEATURE_ENABLED=True
|
||||
FRONTEND_URL_JSON_FOOTER=http://frontend:3000/contents/footer-demo.json
|
||||
|
||||
@@ -1692,6 +1692,7 @@ class ConfigView(drf.views.APIView):
|
||||
"CRISP_WEBSITE_ID",
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_CSS_URL",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
||||
"FRONTEND_FOOTER_FEATURE_ENABLED",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
|
||||
@@ -1113,11 +1113,17 @@ class DocumentAccess(BaseAccess):
|
||||
"""
|
||||
roles = self._get_roles(self.document, user)
|
||||
is_owner_or_admin = bool(set(roles).intersection(set(PRIVILEGED_ROLES)))
|
||||
|
||||
if self.role == RoleChoices.OWNER:
|
||||
# An owner can only delete its own access if other owners exist
|
||||
can_delete = (
|
||||
RoleChoices.OWNER in roles
|
||||
self.user
|
||||
and self.user.id == user.id
|
||||
and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
||||
)
|
||||
|
||||
# An owner can only update its own access to a non-owner role
|
||||
# and only if other owners exist
|
||||
set_role_to = (
|
||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
||||
if can_delete
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test document accesses API endpoints for users in impress's core app.
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import random
|
||||
from uuid import uuid4
|
||||
@@ -624,14 +625,18 @@ def test_api_document_accesses_update_administrator_to_owner(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.parametrize(
|
||||
"role", [role for role in models.RoleChoices if role != models.RoleChoices.OWNER]
|
||||
)
|
||||
def test_api_document_accesses_update_owner(
|
||||
via,
|
||||
role,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
A user who is an owner in a document should be allowed to update
|
||||
a user access for this document whatever the role.
|
||||
a user access for this document for all roles except owner.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
@@ -648,9 +653,7 @@ def test_api_document_accesses_update_owner(
|
||||
)
|
||||
|
||||
factories.UserFactory()
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
)
|
||||
access = factories.UserDocumentAccessFactory(document=document, role=role)
|
||||
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
@@ -729,11 +732,30 @@ def test_api_document_accesses_update_owner_self(
|
||||
access.refresh_from_db()
|
||||
assert access.role == "owner"
|
||||
|
||||
# Add another owner and it should now work
|
||||
# Add another owner and it should now work only for user, not for team
|
||||
factories.UserDocumentAccessFactory(document=document, role="owner")
|
||||
|
||||
user_id = str(access.user_id) if via == USER else None
|
||||
with mock_reset_connections(document.id, user_id):
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, role="owner")
|
||||
|
||||
user_id = str(access.user_id) if via == USER else None
|
||||
with mock_reset_connections(document.id, user_id):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data={
|
||||
**old_values,
|
||||
"role": new_role,
|
||||
"user_id": old_values.get("user", {}).get("id")
|
||||
if old_values.get("user") is not None
|
||||
else None,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
else:
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data={
|
||||
@@ -745,13 +767,41 @@ def test_api_document_accesses_update_owner_self(
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# Delete
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_owner_from_other_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an owner in a document should not be allowed to update
|
||||
access for another user who is also an owner in the document.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
other_owner = factories.UserFactory()
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document, user=other_owner, role="owner"
|
||||
)
|
||||
|
||||
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": models.RoleChoices.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_document_accesses_delete_anonymous():
|
||||
@@ -898,14 +948,18 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.parametrize(
|
||||
"role", [role for role in models.RoleChoices if role != models.RoleChoices.OWNER]
|
||||
)
|
||||
def test_api_document_accesses_delete_owners(
|
||||
via,
|
||||
role,
|
||||
mock_user_teams,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
Users should be able to delete the document access of another user
|
||||
for a document of which they are owner.
|
||||
for a document of which they are owner but can not delete other owners.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -921,7 +975,7 @@ def test_api_document_accesses_delete_owners(
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
access = factories.UserDocumentAccessFactory(document=document, role=role)
|
||||
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
@@ -935,6 +989,79 @@ def test_api_document_accesses_delete_owners(
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_delete_other_owners(via, mock_user_teams):
|
||||
"""
|
||||
A user should not be able to delete the document access of another owner.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
access = factories.UserDocumentAccessFactory(document=document, role="owner")
|
||||
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_delete_self_owner_if_other_owner_exists(
|
||||
via,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
A user should be able to delete their own ownership access if there is another owner in the
|
||||
document.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = None
|
||||
if via == USER:
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role="owner"
|
||||
)
|
||||
elif via == TEAM:
|
||||
pytest.skip("Implement when team ownership is implemented")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=document, role="owner")
|
||||
|
||||
assert (
|
||||
models.DocumentAccess.objects.filter(document=document, role="owner").count()
|
||||
== 2
|
||||
)
|
||||
with mock_reset_connections(document.id, str(access.user_id)):
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert (
|
||||
models.DocumentAccess.objects.filter(document=document, role="owner").count()
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
|
||||
"""
|
||||
@@ -957,10 +1084,16 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert (
|
||||
models.DocumentAccess.objects.filter(document=document, role="owner").count()
|
||||
== 1
|
||||
)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert (
|
||||
models.DocumentAccess.objects.filter(document=document, role="owner").count()
|
||||
== 1
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ pytestmark = pytest.mark.django_db
|
||||
COLLABORATION_WS_URL="http://testcollab/",
|
||||
CRISP_WEBSITE_ID="123",
|
||||
FRONTEND_CSS_URL="http://testcss/",
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED=True,
|
||||
FRONTEND_FOOTER_FEATURE_ENABLED=True,
|
||||
FRONTEND_THEME="test-theme",
|
||||
MEDIA_BASE_URL="http://testserver/",
|
||||
@@ -41,6 +42,7 @@ def test_api_config(is_authenticated):
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FRONTEND_CSS_URL": "http://testcss/",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
|
||||
"FRONTEND_FOOTER_FEATURE_ENABLED": True,
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
"LANGUAGES": [
|
||||
|
||||
@@ -150,6 +150,23 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
|
||||
document=access.document, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_document_access_get_abilities_for_owner_of_self_with_other_owner():
|
||||
"""
|
||||
Check abilities of self access for the owner of a document when there is at least one other
|
||||
owner left.
|
||||
"""
|
||||
access = factories.UserDocumentAccessFactory(role="owner")
|
||||
factories.UserDocumentAccessFactory(document=access.document, role="owner")
|
||||
abilities = access.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
|
||||
@@ -410,6 +410,11 @@ class Base(Configuration):
|
||||
FRONTEND_THEME = values.Value(
|
||||
None, environ_name="FRONTEND_THEME", environ_prefix=None
|
||||
)
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_URL_JSON_FOOTER = values.Value(
|
||||
None, environ_name="FRONTEND_URL_JSON_FOOTER", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export const CONFIG = {
|
||||
AI_FEATURE_ENABLED: true,
|
||||
CRISP_WEBSITE_ID: null,
|
||||
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
|
||||
ENVIRONMENT: 'development',
|
||||
FRONTEND_CSS_URL: null,
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
|
||||
FRONTEND_FOOTER_FEATURE_ENABLED: true,
|
||||
FRONTEND_THEME: 'default',
|
||||
MEDIA_BASE_URL: 'http://localhost:8083',
|
||||
LANGUAGES: [
|
||||
['en-us', 'English'],
|
||||
['fr-fr', 'Français'],
|
||||
['de-de', 'Deutsch'],
|
||||
['nl-nl', 'Nederlands'],
|
||||
],
|
||||
LANGUAGE_CODE: 'en-us',
|
||||
POSTHOG_KEY: {},
|
||||
SENTRY_DSN: null,
|
||||
};
|
||||
|
||||
export const keyCloakSignIn = async (
|
||||
page: Page,
|
||||
browserName: string,
|
||||
|
||||
@@ -2,27 +2,7 @@ import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './common';
|
||||
|
||||
const config = {
|
||||
AI_FEATURE_ENABLED: true,
|
||||
CRISP_WEBSITE_ID: null,
|
||||
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
|
||||
ENVIRONMENT: 'development',
|
||||
FRONTEND_CSS_URL: null,
|
||||
FRONTEND_FOOTER_FEATURE_ENABLED: true,
|
||||
FRONTEND_THEME: 'default',
|
||||
MEDIA_BASE_URL: 'http://localhost:8083',
|
||||
LANGUAGES: [
|
||||
['en-us', 'English'],
|
||||
['fr-fr', 'Français'],
|
||||
['de-de', 'Deutsch'],
|
||||
['nl-nl', 'Nederlands'],
|
||||
],
|
||||
LANGUAGE_CODE: 'en-us',
|
||||
POSTHOG_KEY: {},
|
||||
SENTRY_DSN: null,
|
||||
};
|
||||
import { CONFIG, createDoc } from './common';
|
||||
|
||||
test.describe('Config', () => {
|
||||
test('it checks the config api is called', async ({ page }) => {
|
||||
@@ -36,7 +16,7 @@ test.describe('Config', () => {
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
expect(await response.json()).toStrictEqual(config);
|
||||
expect(await response.json()).toStrictEqual(CONFIG);
|
||||
});
|
||||
|
||||
test('it checks that sentry is trying to init from config endpoint', async ({
|
||||
@@ -47,7 +27,7 @@ test.describe('Config', () => {
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...config,
|
||||
...CONFIG,
|
||||
SENTRY_DSN: 'https://sentry.io/123',
|
||||
},
|
||||
});
|
||||
@@ -120,7 +100,7 @@ test.describe('Config', () => {
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...config,
|
||||
...CONFIG,
|
||||
AI_FEATURE_ENABLED: false,
|
||||
},
|
||||
});
|
||||
@@ -151,7 +131,7 @@ test.describe('Config', () => {
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...config,
|
||||
...CONFIG,
|
||||
CRISP_WEBSITE_ID: '1234',
|
||||
},
|
||||
});
|
||||
@@ -173,7 +153,7 @@ test.describe('Config', () => {
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...config,
|
||||
...CONFIG,
|
||||
FRONTEND_CSS_URL: 'http://localhost:123465/css/style.css',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { CONFIG } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/docs/');
|
||||
});
|
||||
@@ -50,4 +52,27 @@ test.describe('Home page', () => {
|
||||
|
||||
await expect(footer).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks the homepage feature flag', async ({ page }) => {
|
||||
await page.route('**/api/v1.0/config/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...CONFIG,
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Keyclock login page
|
||||
await expect(
|
||||
page.locator('.login-pf-page-header').getByText('impress'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,18 @@ import { Theme } from '@/cunningham/';
|
||||
import { PostHogConf } from '@/services';
|
||||
|
||||
interface ConfigResponse {
|
||||
LANGUAGES: [string, string][];
|
||||
LANGUAGE_CODE: string;
|
||||
ENVIRONMENT: string;
|
||||
AI_FEATURE_ENABLED?: boolean;
|
||||
COLLABORATION_WS_URL?: string;
|
||||
CRISP_WEBSITE_ID?: string;
|
||||
FRONTEND_THEME?: Theme;
|
||||
ENVIRONMENT: string;
|
||||
FRONTEND_CSS_URL?: string;
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean;
|
||||
FRONTEND_THEME?: Theme;
|
||||
LANGUAGES: [string, string][];
|
||||
LANGUAGE_CODE: string;
|
||||
MEDIA_BASE_URL?: string;
|
||||
POSTHOG_KEY?: PostHogConf;
|
||||
SENTRY_DSN?: string;
|
||||
AI_FEATURE_ENABLED?: boolean;
|
||||
}
|
||||
|
||||
export const getConfig = async (): Promise<ConfigResponse> => {
|
||||
|
||||
@@ -3,14 +3,16 @@ import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useConfig } from '@/core';
|
||||
|
||||
import { useAuth } from '../hooks';
|
||||
import { getAuthUrl } from '../utils';
|
||||
import { getAuthUrl, gotoLogin } from '../utils';
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } =
|
||||
useAuth();
|
||||
const { replace, pathname } = useRouter();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
if (isLoading && !isFetchedAfterMount) {
|
||||
return (
|
||||
@@ -40,7 +42,11 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
* If the user is not authenticated and the path is not allowed, we redirect to the login page.
|
||||
*/
|
||||
if (!authenticated && !pathAllowed) {
|
||||
void replace('/login');
|
||||
if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) {
|
||||
void replace('/home');
|
||||
} else {
|
||||
gotoLogin();
|
||||
}
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
<Loader />
|
||||
@@ -49,9 +55,9 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is authenticated and the path is the login page, we redirect to the home page.
|
||||
* If the user is authenticated and the path is the home page, we redirect to the index.
|
||||
*/
|
||||
if (pathname === '/login' && authenticated) {
|
||||
if (pathname === '/home' && authenticated) {
|
||||
void replace('/');
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { HomeContent } from '@/features/home';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
return <HomeContent />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -50,6 +50,7 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true
|
||||
FRONTEND_FOOTER_FEATURE_ENABLED: true
|
||||
FRONTEND_URL_JSON_FOOTER: https://impress.127.0.0.1.nip.io/contents/footer-demo.json
|
||||
POSTGRES_DB: impress
|
||||
|
||||
Reference in New Issue
Block a user