Compare commits

..

11 Commits

Author SHA1 Message Date
Lebaud Antoine 64150da81a wip 2024-02-15 19:17:30 +01:00
Lebaud Antoine 6c0acd0596 ✏️(project) fix typos
Found typos, fixed them.
2024-02-15 16:48:00 +01:00
Lebaud Antoine 625e98515b 🔧(backend) activate container liveness probes
Enabled Dockerflow Django app by activating liveness probes. The previously
unavailable routes such as `__heartbeat__` and `__lbheartbeat__` are now
accessible. New endpoints include:
* GET /__version__
* GET /__heartbeat__
* GET /__lbheartbeat__
2024-02-15 16:48:00 +01:00
Lebaud Antoine 297007162a (frontend) introduce frontend Docker Image
To facilitate deployment on Kubernetes, we've introduced a Docker image for the
frontend. The Next project is built, and its static output is served using an
Nginx reverse proxy.

Since DevOps lacks a certified cold storage solution (e.g., S3) for serving
static files, we've opted to containerize the frontend as a quick workaround for
deploying staging environments.
2024-02-15 16:48:00 +01:00
Lebaud Antoine 8fa947f24d ⬆️(project) upgrade base Docker image for mail-builder
Updated to Node Image version 20 to align with the upcoming frontend image.
2024-02-15 16:48:00 +01:00
Lebaud Antoine ee580ad258 🐛(project) run production image locally with docker-compose
The local deployment of the Production image through docker-compose was
failing due to issues in the Django configurations, influenced by Joanie.

The bug stemmed from a dependency on a development-specific package
(drf-spectacular-sidecar) while attempting to run the application in
production mode.

Changes Made:
- Introduced new Django settings for local demo environments.
- Uncommented the nginx configuration to address the production image
  deployment issues.
2024-02-15 16:48:00 +01:00
Lebaud Antoine 155043e0eb wip replace Implicit flow by Authorization Code flow in frontend
Instead of interacting with Keycloak, the frontend navigate to the
/authenticate endpoint, which starts the Authorization code flow.

When the flow is done, the backend redirect back to the SPA,
passing a session cookie and a csrf cookie.

Done:
* Query GET user/me to determine if user is authenticated yet
* Remove Keycloak js dependency, as all the OIDC logic is handled by the backend
* Store user's data instead of the JWT token

Todo in a dedicated PR:
* Implement the home screen with the SSO login buton
* Handle Logout properly
* Intercept 401 and 403 error properly
2024-02-15 16:46:32 +01:00
Lebaud Antoine df03534fb1 wip handle Authorization code flow with Django
Integrate 'mozilla-django-oidc' dependency, to support
Authorization Code flow, which is required by Agent Connect.

Thus, we provide a secure back channel OIDC flow, and return
to the client only a session cookie.

Done:
* Replace JWT authentication by Session based authentication in DRF
* Update Django settings to make OIDC configurations easily editable
* Add 'mozilla-django-oidc' routes to our router
* Implement a custom Django Authentication class to adapt 'mozilla-django-oidc' to our needs

'mozilla-django-oidc' routes added are:
* /authenticate
* /callback (the redirect_uri called back by the Idp)
* /logout

Todo in dedicated PR:
* Configure redis to manage these session cookies
* Test and adjust code if necessary when the session cookie expires
* Test and adjust code if necessary when the user logout
2024-02-15 16:46:16 +01:00
Lebaud Antoine 714d8188f5 wip drop JWT authentication in API tests
force login to bypass authorization checks when necessary.

Note: Generating a session cookie through OIDC flow
is not supported while testing our API.
2024-02-15 12:03:05 +01:00
Lebaud Antoine 65b36c6116 wip proxy keycloak with nginx
Backend and Frontend send requests to Keycloak through Nginx.

Thus, all requests from frontend and backend shared a same host
when received by Keycloak.

Otherwise, the flow is initiated from http://localhost:8080. When the Backend
calls token endpoint from Keycloak container at http://keycloak:8080,
the JWT token issuer and sender are mismatching.
2024-02-15 12:02:53 +01:00
Lebaud Antoine 52d8969602 wip update keycloak config
* rename client people-front to people
* add a client secret shared with the backend
* add allowed redirect uris
* disable implicit flow and enable Authorization Code flow without PCKE
* sign userinfo endpoint to return application/jwt content
2024-02-14 22:35:15 +01:00
141 changed files with 2838 additions and 5663 deletions
+13 -3
View File
@@ -35,10 +35,20 @@ jobs:
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Build and push
name: Build and push backend
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
target: production
push: true
tags: app-${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: .
target: frontend
push: true
tags: frontend-${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+2 -3
View File
@@ -115,7 +115,6 @@ jobs:
- name: Set services env variables
run: |
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Build and Start Docker Servers
env:
@@ -125,7 +124,7 @@ jobs:
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make run
- name: Apply DRF migrations
- name: Apply DRH migrations
run: |
make migrate
@@ -217,7 +216,7 @@ jobs:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: people.settings
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
DJANGO_JWT_PRIVATE_SIGNING_KEY: ThisIsAnExampleKeyForDevPurposeOnly
DB_HOST: localhost
DB_NAME: people
DB_USER: dinum
+35 -1
View File
@@ -11,6 +11,40 @@ RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
### ---- Front-end builder image ----
FROM node:20 as front-builder
# Copy frontend app sources
COPY ./src/frontend /builder
WORKDIR /builder/apps/desk
RUN yarn install --frozen-lockfile && \
yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=front-builder \
/builder/apps/desk/out \
/usr/share/nginx/html
COPY ./docker/files/etc/nginx/conf.d /etc/nginx/conf.d:ro
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
# ---- Back-end builder image ----
FROM base as back-builder
@@ -24,7 +58,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:18 as mail-builder
FROM node:20 as mail-builder
COPY ./src/mail /mail/app
+3 -1
View File
@@ -92,8 +92,10 @@ bootstrap: \
.PHONY: bootstrap
# -- Docker/compose
build: ## build the app-dev container
build: ## build the dev and demo containers
@$(COMPOSE) build app-dev
@$(COMPOSE) build nginx
@$(COMPOSE) build app
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
+9 -6
View File
@@ -67,9 +67,12 @@ services:
image: people:production
environment:
- DJANGO_CONFIGURATION=Demo
- DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:8088
env_file:
- env.d/development/common
- env.d/development/postgresql
ports:
- "8082:8000"
volumes:
- ./data/media:/data/media
depends_on:
@@ -89,15 +92,15 @@ services:
- app
nginx:
image: nginx:1.25
build:
context: .
target: frontend
image: people:frontend
ports:
- "8082:8082"
- "8083:8083"
- "8088:8088"
- "8083:8083"
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
- ./src/frontend/apps/desk/out:/home/desk
- ./data/media:/data/media:ro
depends_on:
- app
- keycloak
@@ -168,7 +171,7 @@ services:
KC_DB_PASSWORD: pass
KC_DB_USERNAME: people
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
PROXY_ADDRESS_FORWARDING: true
ports:
- "8080:8080"
depends_on:
+6 -40
View File
@@ -46,9 +46,6 @@
"users": [
{
"username": "people",
"email": "people@people.world",
"firstName": "John",
"lastName": "Doe",
"enabled": true,
"credentials": [
{
@@ -59,43 +56,12 @@
"realmRoles": ["user"]
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.e2e",
"firstName": "E2E",
"lastName": "Chromium",
"username": "user-e2e",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e-chromium"
}
],
"realmRoles": ["user"]
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e-webkit"
}
],
"realmRoles": ["user"]
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e-firefox"
"value": "password-e2e"
}
],
"realmRoles": ["user"]
@@ -1110,7 +1076,7 @@
},
{
"id": "79712bcf-b7f7-4ca3-b97c-418f48fded9b",
"name": "first name",
"name": "given name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
@@ -1119,7 +1085,7 @@
"user.attribute": "firstName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "first_name",
"claim.name": "given_name",
"jsonType.label": "String"
}
},
@@ -1140,7 +1106,7 @@
},
{
"id": "7f741e96-41fe-4021-bbfd-506e7eb94e69",
"name": "last name",
"name": "family name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
@@ -1149,7 +1115,7 @@
"user.attribute": "lastName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "last_name",
"claim.name": "family_name",
"jsonType.label": "String"
}
},
+2 -20
View File
@@ -1,27 +1,9 @@
server {
listen 8082;
server_name localhost;
charset utf-8;
location /media {
alias /data/media;
}
location / {
proxy_pass http://app:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
server {
listen 8088;
server_name localhost;
root /home/desk;
root /usr/share/nginx/html;
location / {
try_files $uri index.html $uri/ =404;
}
-3
View File
@@ -1,3 +0,0 @@
# For the CI job test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
-14
View File
@@ -13,20 +13,6 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["node", "node-fetch", "i18next-parser"]
},
{
"enabled": false,
"groupName": "ignored js @types/react-dom@18.2.19",
"matchManagers": ["npm"],
"matchPackageNames": ["@types/react-dom"],
"allowedVersions": ">18.2.19"
},
{
"enabled": false,
"groupName": "ignored js @openfun/cunningham-react@2.5.0",
"matchManagers": ["npm"],
"matchPackageNames": ["@openfun/cunningham-react"],
"allowedVersions": ">2.5.0"
}
]
}
-37
View File
@@ -82,15 +82,6 @@ class UserAdmin(auth_admin.UserAdmin):
),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
},
),
)
inlines = (IdentityInline, TeamAccessInline)
list_display = (
"email",
@@ -119,31 +110,3 @@ class TeamAdmin(admin.ModelAdmin):
"updated_at",
)
search_fields = ("name",)
@admin.register(models.Invitation)
class InvitationAdmin(admin.ModelAdmin):
"""Admin interface to handle invitations."""
fields = (
"email",
"team",
"role",
"created_at",
"issuer",
)
readonly_fields = (
"created_at",
"is_expired",
"issuer",
)
list_display = (
"email",
"team",
"created_at",
"is_expired",
)
def save_model(self, request, obj, form, change):
obj.issuer = request.user
obj.save()
+10 -92
View File
@@ -26,66 +26,28 @@ class ContactSerializer(serializers.ModelSerializer):
return super().update(instance, validated_data)
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""
A ModelSerializer that takes an additional `fields` argument that
controls which fields should be displayed.
"""
def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop("fields", None)
# Instantiate the superclass normally
super().__init__(*args, **kwargs)
if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)
class UserSerializer(DynamicFieldsModelSerializer):
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
data = serializers.SerializerMethodField(read_only=True)
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
name = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = [
"id",
"name",
"email",
"data",
"language",
"timezone",
"is_device",
"is_staff",
]
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
read_only_fields = ["id", "email", "data", "is_device", "is_staff"]
def _get_main_identity_attr(self, obj, attribute_name):
"""Return the specified attribute of the main identity."""
try:
return getattr(obj.main_identity[0], attribute_name)
except TypeError:
return getattr(obj.main_identity, attribute_name)
except IndexError:
main_identity = obj.identities.filter(is_main=True).first()
return getattr(obj.main_identity, attribute_name) if main_identity else None
except AttributeError:
return None
def get_name(self, obj):
"""Return main identity's name."""
return self._get_main_identity_attr(obj, "name")
def get_email(self, obj):
"""Return main identity's email."""
return self._get_main_identity_attr(obj, "email")
def get_data(self, user) -> dict:
"""Return contact data for the user."""
return user.profile_contact.data if user.profile_contact else {}
class TeamAccessSerializer(serializers.ModelSerializer):
@@ -164,52 +126,17 @@ class TeamAccessSerializer(serializers.ModelSerializer):
return attrs
class TeamAccessReadOnlySerializer(TeamAccessSerializer):
"""Serialize team accesses for list and retrieve actions."""
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
class Meta:
model = models.TeamAccess
fields = [
"id",
"user",
"role",
"abilities",
]
read_only_fields = [
"id",
"user",
"role",
"abilities",
]
class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TeamAccessSerializer(many=True, read_only=True)
slug = serializers.SerializerMethodField()
class Meta:
model = models.Team
fields = [
"id",
"name",
"accesses",
"abilities",
"slug",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"slug",
"created_at",
"updated_at",
]
fields = ["id", "name", "accesses", "abilities", "slug"]
read_only_fields = ["id", "accesses", "abilities", "slug"]
def get_abilities(self, team) -> dict:
"""Return abilities of the logged-in user on the instance."""
@@ -221,12 +148,3 @@ class TeamSerializer(serializers.ModelSerializer):
def get_slug(self, instance):
"""Return slug from the team's name."""
return instance.get_slug()
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
class Meta:
model = models.Invitation
fields = ["email", "team", "role", "issuer"]
read_only_fields = ["team", "issuer"]
+14
View File
@@ -0,0 +1,14 @@
"""
Utils that can be useful throughout the People core app
"""
from rest_framework_simplejwt.tokens import RefreshToken
def get_tokens_for_user(user):
"""Get JWT tokens for user authentication."""
refresh = RefreshToken.for_user(user)
return {
"refresh": str(refresh),
"access": str(refresh.access_token),
}
+6 -33
View File
@@ -1,11 +1,10 @@
"""API endpoints"""
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Func, Max, OuterRef, Prefetch, Q, Subquery, Value
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
from rest_framework import (
decorators,
exceptions,
filters,
mixins,
pagination,
response,
@@ -186,7 +185,7 @@ class UserViewSet(
"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all()
queryset = models.User.objects.all().select_related("profile_contact")
serializer_class = serializers.UserSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
pagination_class = Pagination
@@ -199,12 +198,6 @@ class UserViewSet(
# Exclude inactive contacts
queryset = queryset.filter(
is_active=True,
).prefetch_related(
Prefetch(
"identities",
queryset=models.Identity.objects.filter(is_main=True),
to_attr="main_identity",
)
)
# Search by case-insensitive and accent-insensitive trigram similarity
@@ -233,12 +226,9 @@ class UserViewSet(
"""
Return information on currently logged user
"""
user = request.user
user.main_identity = models.Identity.objects.filter(
user=user, is_main=True
).first()
context = {"request": request}
return response.Response(
self.serializer_class(user, context={"request": request}).data
self.serializer_class(request.user, context=context).data
)
@@ -254,9 +244,6 @@ class TeamViewSet(
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at"]
ordering = ["-created_at"]
queryset = models.Team.objects.all()
def get_queryset(self):
@@ -314,8 +301,7 @@ class TeamAccessViewSet(
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
queryset = models.TeamAccess.objects.all().select_related("user")
list_serializer_class = serializers.TeamAccessReadOnlySerializer
detail_serializer_class = serializers.TeamAccessSerializer
serializer_class = serializers.TeamAccessSerializer
def get_permissions(self):
"""User only needs to be authenticated to list team accesses"""
@@ -332,35 +318,22 @@ class TeamAccessViewSet(
context["team_id"] = self.kwargs["team_id"]
return context
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return self.list_serializer_class
return self.detail_serializer_class
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(team=self.kwargs["team_id"])
if self.action in {"list", "retrieve"}:
if self.action == "list":
# Limit to team access instances related to a team THAT also has a team access
# instance for the logged-in user (we don't want to list only the team access
# instances pointing to the logged-in user)
user_role_query = models.TeamAccess.objects.filter(
team__accesses__user=self.request.user
).values("role")[:1]
queryset = (
queryset.filter(
team__accesses__user=self.request.user,
)
.prefetch_related(
Prefetch(
"user__identities",
queryset=models.Identity.objects.filter(is_main=True),
to_attr="main_identity",
)
)
.annotate(user_role=Subquery(user_role_query))
.distinct()
)
+23 -33
View File
@@ -1,6 +1,7 @@
"""Authentication for the People core app."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
import logging
from django.db import models
from django.utils.translation import gettext_lazy as _
@@ -8,15 +9,18 @@ import requests
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from rest_framework_simplejwt.exceptions import InvalidToken
from .models import Identity
LOGGER = logging.getLogger(__name__)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
in the User and Identity models, as well as the userinfo endpoint behavior in Agent Connect.
"""
def get_userinfo(self, access_token, id_token, payload):
@@ -30,9 +34,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
Note: The id_token and payload parameters are unused in this implementation,
but were kept to preserve base method signature.
Note: It handles signed and/or encrypted UserInfo Response. It is required by
Agent Connect, which follows the OIDC standard. It forces us to override the
base method, which deal with 'application/json' response.
Note: Userinfo endpoint returns a signed JWT, and not 'application/json'. This
constraint comes from Agent Connect. It forces us to override the base method,
which deal with 'application/json' response.
Returns:
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
@@ -66,39 +70,24 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
user_info = self.get_userinfo(access_token, id_token, payload)
# Compute user name from OIDC name fields as defined in settings
names_list = [
user_info[field]
for field in settings.USER_OIDC_FIELDS_TO_NAME
if user_info.get(field)
]
user_info["name"] = " ".join(names_list) or None
email = user_info.get("email")
sub = user_info.get("sub")
if sub is None:
raise SuspiciousOperation(
raise InvalidToken(
_("User info contained no recognizable user identification")
)
user = (
self.UserModel.objects.filter(identities__sub=sub)
.annotate(
identity_email=models.F("identities__email"),
identity_name=models.F("identities__name"),
)
.annotate(identity_email=models.F("identities__email"))
.distinct()
.first()
)
if user:
email = user_info.get("email")
name = user_info.get("name")
if (
email
and email != user.identity_email
or name
and name != user.identity_name
):
Identity.objects.filter(sub=sub).update(email=email, name=name)
if email and email != user.identity_email:
Identity.objects.filter(sub=sub).update(email=email)
elif self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
@@ -107,15 +96,16 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
def create_user(self, claims):
"""Return a newly created User instance."""
email = claims.get("email")
sub = claims.get("sub")
if sub is None:
raise SuspiciousOperation(
raise InvalidToken(
_("Claims contained no recognizable user identification")
)
user = self.UserModel.objects.create(password="!") # noqa: S106
Identity.objects.create(
user=user, sub=sub, email=claims.get("email"), name=claims.get("name")
)
user = self.UserModel.objects.create(password="!", email=email) # noqa: S106
Identity.objects.create(user=user, sub=sub, email=email)
return user
-13
View File
@@ -139,7 +139,6 @@ class IdentityFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
name = factory.Faker("name")
class TeamFactory(factory.django.DjangoModelFactory):
@@ -171,15 +170,3 @@ class TeamAccessFactory(factory.django.DjangoModelFactory):
team = factory.SubFactory(TeamFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""
class Meta:
model = models.Invitation
email = factory.Faker("email")
team = factory.SubFactory(TeamFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)
+4 -20
View File
@@ -1,6 +1,6 @@
# Generated by Django 5.0.2 on 2024-02-23 06:46
# Generated by Django 5.0.1 on 2024-02-06 15:08
import django.contrib.auth.models
import core.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(editable=False, max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
],
options={
'verbose_name': 'Team',
@@ -60,7 +60,7 @@ class Migration(migrations.Migration):
'db_table': 'people_user',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
('objects', core.models.UserManager()),
],
),
migrations.CreateModel(
@@ -95,7 +95,6 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)),
],
@@ -106,21 +105,6 @@ class Migration(migrations.Migration):
'ordering': ('-is_main', 'email'),
},
),
migrations.CreateModel(
name='Invitation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.team')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='TeamAccess',
fields=[
+12 -34
View File
@@ -4,14 +4,12 @@ Declare and configure the models for the People core application
import json
import os
import uuid
from datetime import timedelta
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import exceptions, mail, validators
from django.db import models
from django.utils import timezone
from django.utils.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
@@ -147,6 +145,16 @@ class Contact(BaseModel):
raise exceptions.ValidationError({"data": [error_message]}) from e
class UserManager(auth_models.UserManager):
"""
Override user manager to get the related contact in the same query by default (Contact model)
"""
def get_queryset(self):
"""Always select the related contact when doing a query on users."""
return super().get_queryset().select_related("profile_contact")
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
@@ -190,7 +198,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
)
objects = auth_models.UserManager()
objects = UserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
@@ -254,7 +262,6 @@ class Identity(BaseModel):
validators=[sub_validator],
)
email = models.EmailField(_("email address"), null=True, blank=True)
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
is_main = models.BooleanField(
_("main"),
default=False,
@@ -279,8 +286,7 @@ class Identity(BaseModel):
def __str__(self):
main_str = "[main]" if self.is_main else ""
id_str = self.email or self.sub
return f"{id_str:s}{main_str:s}"
return f"{self.email:s}{main_str:s}"
def save(self, *args, **kwargs):
"""Ensure users always have one and only one main identity."""
@@ -442,31 +448,3 @@ class TeamAccess(BaseModel):
"put": bool(set_role_to),
"set_role_to": set_role_to,
}
class Invitation(BaseModel):
"""User invitation to teams."""
email = models.EmailField(_("email address"), null=False, blank=False)
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
related_name="invitations",
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
issuer = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="invitations",
)
def __str__(self):
return f"{self.email} invited to {self.team}"
@property
def is_expired(self):
"""Calculate if invitation is still valid or has expired."""
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.created_at + validity_duration)
@@ -1,175 +0,0 @@
"""
Test for team accesses API endpoints in People's core app : create
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_create_anonymous():
"""Anonymous users should not be allowed to create team accesses."""
user = factories.UserFactory()
team = factories.TeamFactory()
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(user.id),
"team": str(team.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TeamAccess.objects.exists() is False
def test_api_team_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create team accesses for a team to
which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
other_user = factories.UserFactory()
team = factories.TeamFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_member():
"""Members of a team should not be allowed to create team accesses."""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_administrator():
"""
Administrators of a team should be able to create team accesses except for the "owner" role.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a team can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_create_authenticated_owner():
"""
Owners of a team should be able to create team accesses whatever the role.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
@@ -1,164 +0,0 @@
"""
Test for team accesses API endpoints in People's core app : delete
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a team access."""
access = factories.TeamAccessFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a team access for a
team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_member():
"""
Authenticated users should not be allowed to delete a team access for a
team in which they are a simple member.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_administrators():
"""
Users who are administrators in a team should be allowed to delete an access
from the team provided it is not ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_except_owners():
"""
Users should be able to delete the team access of another user
for a team of which they are owner provided it is not an owner access.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_for_owners():
"""
Users should not be allowed to delete the team access of another owner
even for a team in which they are direct owner.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a team
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
assert models.TeamAccess.objects.count() == 1
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
@@ -1,187 +0,0 @@
"""
Test for team accesses API endpoints in People's core app : list
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_list_anonymous():
"""Anonymous users should not be allowed to list team accesses."""
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(2, team=team)
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list team accesses for a team
to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(3, team=team)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_team_accesses_list_authenticated_related():
"""
Authenticated users should be able to list team accesses for a team
to which they are related, whatever their role in the team.
"""
identity = factories.IdentityFactory(is_main=True)
user = identity.user
team = factories.TeamFactory()
user_access = models.TeamAccess.objects.create(team=team, user=user) # random role
# other team members should appear
other_member = factories.UserFactory()
other_member_identity = factories.IdentityFactory(is_main=True, user=other_member)
access1 = factories.TeamAccessFactory.create(team=team, user=other_member)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 2
assert sorted(response.json()["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": {
"id": str(user_access.user.id),
"email": str(identity.email),
"name": str(identity.name),
},
"role": str(user_access.role),
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": {
"id": str(access1.user.id),
"email": str(other_member_identity.email),
"name": str(other_member_identity.name),
},
"role": str(access1.role),
"abilities": access1.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_team_accesses_list_authenticated_main_identity():
"""
Name and email should be returned from main identity only
"""
user = factories.UserFactory()
identity = factories.IdentityFactory(user=user, is_main=True)
factories.IdentityFactory(user=user) # additional non-main identity
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user) # random role
# other team members should appear, with correct identity
other_user = factories.UserFactory()
other_main_identity = factories.IdentityFactory(is_main=True, user=other_user)
factories.IdentityFactory(user=other_user)
factories.TeamAccessFactory.create(team=team, user=other_user)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 2
users_info = [
(access["user"]["email"], access["user"]["name"])
for access in response.json()["results"]
]
# user information should be returned from main identity
assert sorted(users_info) == sorted(
[
(str(identity.email), str(identity.name)),
(str(other_main_identity.email), str(other_main_identity.name)),
]
)
def test_api_team_accesses_list_authenticated_constant_numqueries(
django_assert_num_queries,
):
"""
The number of queries should not depend on the amount of fetched accesses.
"""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user) # random role
client = APIClient()
client.force_login(user)
# Only 4 queries are needed to efficiently fetch team accesses,
# related users and identities :
# - query retrieving logged-in user for user_role annotation
# - count from pagination
# - query prefetching users' main identity
# - distinct from viewset
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
# create 20 new team members
for _ in range(20):
extra_user = factories.IdentityFactory(is_main=True).user
factories.TeamAccessFactory(team=team, user=extra_user)
# num queries should still be 4
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 21
@@ -1,86 +0,0 @@
"""
Test for team accesses API endpoints in People's core app : retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_team_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a team access.
"""
access = factories.TeamAccessFactory()
response = APIClient().get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a team access for
a team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory(team=factories.TeamFactory())
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
# Accesses related to another team should be excluded even if the user is related to it
for other_access in [
factories.TeamAccessFactory(),
factories.TeamAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{other_access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_team_accesses_retrieve_authenticated_related():
"""
A user who is related to a team should be allowed to retrieve the
associated team user accesses.
"""
identity = factories.IdentityFactory(is_main=True)
user = identity.user
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": {
"id": str(access.user.id),
"email": str(identity.email),
"name": str(identity.name),
},
"role": str(access.role),
"abilities": access.get_abilities(user),
}
@@ -1,340 +0,0 @@
"""
Test for team accesses API endpoints in People's core app : update
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_team_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a team access."""
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = APIClient().put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a team access for a team to which
they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_member():
"""Members of a team should not be allowed to update its accesses."""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_except_owner():
"""
A user who is an administrator in a team should be allowed to update a user
access for this team, as long as they don't try to set the role to owner.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "member"]),
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_administrator_from_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of an "owner" for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_to_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of another user to grant team ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
user=other_user,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_except_owner():
"""
A user who is an owner in a team should be allowed to update
a user access for this team except for existing "owner" accesses.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_owner_for_owners():
"""
A user who is "owner" of a team should not be allowed to update
an existing owner access for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
content_type="application/json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_self():
"""
A user who is owner of a team should be allowed to update
their own user access provided there are other owners in the team.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "member"])
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.TeamAccessFactory(team=team, role="owner")
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
@@ -26,10 +26,7 @@ def test_api_teams_list_anonymous():
def test_api_teams_list_authenticated():
"""
Authenticated users should be able to list teams
they are an owner/administrator/member of.
"""
"""Authenticated users should be able to list teams they are an owner/administrator/member of."""
identity = factories.IdentityFactory()
user = identity.user
@@ -122,61 +119,3 @@ def test_api_teams_list_authenticated_distinct():
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(team.id)
def test_api_teams_order():
"""
Test that the endpoint GET teams is sorted in 'created_at' descending order by default.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team_ids = [
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
]
response = client.get(
"/api/v1.0/teams/",
)
assert response.status_code == 200
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
team_ids.reverse()
assert (
response_team_ids == team_ids
), "created_at values are not sorted from newest to oldest"
def test_api_teams_order_param():
"""
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team_ids = [
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
]
response = client.get(
"/api/v1.0/teams/?ordering=created_at",
)
assert response.status_code == 200
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
assert (
response_team_ids == team_ids
), "created_at values are not sorted from oldest to newest"
@@ -58,19 +58,28 @@ def test_api_teams_retrieve_authenticated_related():
response = client.get(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_200_OK
assert sorted(response.json().pop("accesses")) == sorted(
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
[
str(access1.id),
str(access2.id),
]
{
"id": str(access1.id),
"user": str(user.id),
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["user"],
)
assert response.json() == {
"id": str(team.id),
"name": team.name,
"slug": team.slug,
"abilities": team.get_abilities(user),
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -121,10 +121,8 @@ def test_api_teams_update_authenticated_administrators():
team.refresh_from_db()
final_values = serializers.TeamSerializer(instance=team).data
for key, value in final_values.items():
if key in ["id", "accesses", "created_at"]:
if key in ["id", "accesses"]: # pylint: disable=R1733
assert value == initial_values[key]
elif key == "updated_at":
assert value > initial_values[key]
else:
# name, slug and abilities successfully modified
assert value == new_values[key]
@@ -155,10 +153,8 @@ def test_api_teams_update_authenticated_owners():
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
for key, value in team_values.items():
if key in ["id", "accesses", "created_at"]:
if key in ["id", "accesses"]:
assert value == old_team_values[key]
elif key == "updated_at":
assert value > old_team_values[key]
else:
# name, slug and abilities successfully modified
assert value == new_team_values[key]
@@ -0,0 +1,846 @@
"""
Test team accesses API endpoints for users in People's core app.
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_team_accesses_list_anonymous():
"""Anonymous users should not be allowed to list team accesses."""
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(2, team=team)
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list team accesses for a team
to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(3, team=team)
client = APIClient()
client.force_login(user)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_team_accesses_list_authenticated_related():
"""
Authenticated users should be able to list team accesses for a team
to which they are related, whatever their role in the team.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
user_access = models.TeamAccess.objects.create(team=team, user=user) # random role
access1, access2 = factories.TeamAccessFactory.create_batch(2, team=team)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": str(user.id),
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": str(access1.user.id),
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_team_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a team access.
"""
access = factories.TeamAccessFactory()
response = APIClient().get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a team access for
a team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team)
response = client.get(f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another team should be excluded even if the user is related to it
for access in [
factories.TeamAccessFactory(),
factories.TeamAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_team_accesses_retrieve_authenticated_related():
"""
A user who is related to a team should be allowed to retrieve the
associated team user accesses.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[user])
access = factories.TeamAccessFactory(team=team)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": str(access.user.id),
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_team_accesses_create_anonymous():
"""Anonymous users should not be allowed to create team accesses."""
user = factories.UserFactory()
team = factories.TeamFactory()
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(user.id),
"team": str(team.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TeamAccess.objects.exists() is False
def test_api_team_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create team accesses for a team to
which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
team = factories.TeamFactory()
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_member():
"""Members of a team should not be allowed to create team accesses."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "member")])
other_user = factories.UserFactory()
for role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_administrator():
"""
Administrators of a team should be able to create team accesses except for the "owner" role.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a team can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_create_authenticated_owner():
"""
Owners of a team should be able to create team accesses whatever the role.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a team access."""
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a team access for a team to which
they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_member():
"""Members of a team should not be allowed to update its accesses."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_except_owner():
"""
A user who is an administrator in a team should be allowed to update a user
access for this team, as long as they don't try to set the role to owner.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "member"]),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_administrator_from_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of an "owner" for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_to_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of another user to grant team ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
user=other_user,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_except_owner():
"""
A user who is an owner in a team should be allowed to update
a user access for this team except for existing "owner" accesses.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_owner_for_owners():
"""
A user who is "owner" of a team should not be allowed to update
an existing owner access for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
content_type="application/json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_self():
"""
A user who is owner of a team should be allowed to update
their own user access provided there are other owners in the team.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "member"])
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.TeamAccessFactory(team=team, role="owner")
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
def test_api_team_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a team access."""
access = factories.TeamAccessFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a team access for a
team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
access = factories.TeamAccessFactory()
response = client.delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_member():
"""
Authenticated users should not be allowed to delete a team access for a
team in which they are a simple member.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_administrators():
"""
Users who are administrators in a team should be allowed to delete an access
from the team provided it is not ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_except_owners():
"""
Users should be able to delete the team access of another user
for a team of which they are owner provided it is not an owner access.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_for_owners():
"""
Users should not be allowed to delete the team access of another owner
even for a team in which they are direct owner.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a team
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
assert models.TeamAccess.objects.count() == 1
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
+16 -65
View File
@@ -58,10 +58,10 @@ def test_api_users_authenticated_list_by_email():
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(email="david.bowman@work.com", is_main=True)
nicole = factories.IdentityFactory(email="nicole_foole@work.com", is_main=True)
frank = factories.IdentityFactory(email="frank_poole@work.com", is_main=True)
factories.IdentityFactory(email="heywood_floyd@work.com", is_main=True)
dave = factories.IdentityFactory(email="david.bowman@work.com")
nicole = factories.IdentityFactory(email="nicole_foole@work.com")
frank = factories.IdentityFactory(email="frank_poole@work.com")
factories.IdentityFactory(email="heywood_floyd@work.com")
# Full query should work
response = client.get(
@@ -91,26 +91,8 @@ def test_api_users_authenticated_list_by_email():
response = client.get("/api/v1.0/users/?q=ool")
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(nicole.user.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.user.is_device,
"is_staff": nicole.user.is_staff,
"language": nicole.user.language,
"timezone": str(nicole.user.timezone),
},
{
"id": str(frank.user.id),
"email": frank.email,
"name": frank.name,
"is_device": frank.user.is_device,
"is_staff": frank.user.is_staff,
"language": frank.user.language,
"timezone": str(frank.user.timezone),
},
]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
def test_api_users_authenticated_list_multiple_identities_single_user():
@@ -150,16 +132,12 @@ def test_api_users_authenticated_list_multiple_identities_multiple_users():
client.force_login(user)
dave = factories.UserFactory()
dave_identity = factories.IdentityFactory(
user=dave, email="david.bowman@work.com", is_main=True
)
davina = factories.UserFactory()
prudence = factories.UserFactory()
factories.IdentityFactory(user=dave, email="david.bowman@work.com")
factories.IdentityFactory(user=dave, email="babibou@ehehe.com")
davina_identity = factories.IdentityFactory(
user=factories.UserFactory(), email="davina.bowan@work.com"
)
prue_identity = factories.IdentityFactory(
user=factories.UserFactory(), email="prudence.crandall@work.com"
)
factories.IdentityFactory(user=davina, email="davina.bowan@work.com")
factories.IdentityFactory(user=prudence, email="prudence.crandall@work.com")
# Full query should work
response = client.get(
@@ -168,35 +146,8 @@ def test_api_users_authenticated_list_multiple_identities_multiple_users():
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 3
assert response.json()["results"] == [
{
"id": str(dave.id),
"email": dave_identity.email,
"name": dave_identity.name,
"is_device": dave_identity.user.is_device,
"is_staff": dave_identity.user.is_staff,
"language": dave_identity.user.language,
"timezone": str(dave_identity.user.timezone),
},
{
"id": str(davina_identity.user.id),
"email": davina_identity.email,
"name": davina_identity.name,
"is_device": davina_identity.user.is_device,
"is_staff": davina_identity.user.is_staff,
"language": davina_identity.user.language,
"timezone": str(davina_identity.user.timezone),
},
{
"id": str(prue_identity.user.id),
"email": prue_identity.email,
"name": prue_identity.name,
"is_device": prue_identity.user.is_device,
"is_staff": prue_identity.user.is_staff,
"language": prue_identity.user.language,
"timezone": str(prue_identity.user.timezone),
},
]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id), str(davina.id), str(prudence.id)]
def test_api_users_authenticated_list_uppercase_content():
@@ -340,7 +291,7 @@ def test_api_users_retrieve_me_anonymous():
def test_api_users_retrieve_me_authenticated():
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
identity = factories.IdentityFactory(is_main=True)
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
@@ -359,12 +310,12 @@ def test_api_users_retrieve_me_authenticated():
assert response.status_code == HTTP_200_OK
assert response.json() == {
"id": str(user.id),
"name": str(identity.name),
"email": str(identity.email),
"email": str(user.email),
"language": user.language,
"timezone": str(user.timezone),
"is_device": False,
"is_staff": False,
"data": user.profile_contact.data,
}
@@ -1,10 +1,11 @@
"""Unit tests for the `get_or_create_user` function."""
from django.core.exceptions import SuspiciousOperation
from unittest import mock
import pytest
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.tokens import AccessToken
from core import models
from core import factories, models
from core.authentication import OIDCAuthenticationBackend
from core.factories import IdentityFactory
@@ -21,7 +22,7 @@ def test_authentication_getter_existing_user_no_email(
klass = OIDCAuthenticationBackend()
# Create a user and its identity
identity = IdentityFactory(name=None)
identity = IdentityFactory()
# Create multiple identities for a user
for _ in range(5):
@@ -34,7 +35,7 @@ def test_authentication_getter_existing_user_no_email(
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
access_token=AccessToken(), id_token=None, payload=None
)
identity.refresh_from_db()
@@ -46,82 +47,45 @@ def test_authentication_getter_existing_user_with_email(
):
"""
When the user's info contains an email and targets an existing user,
it should update the email on the identity but not on the user.
"""
klass = OIDCAuthenticationBackend()
identity = IdentityFactory(name="John Doe")
identity = IdentityFactory()
# Create multiple identities for a user
for _ in range(5):
IdentityFactory(user=identity.user)
user_email = identity.user.email
assert models.User.objects.count() == 1
def get_userinfo_mocked(*args):
return {
"sub": identity.sub,
"email": identity.email,
"first_name": "John",
"last_name": "Doe",
}
return {"sub": identity.sub, "email": identity.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query because email and names have not changed
# Only 1 query if the email has not changed
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
access_token=AccessToken(), id_token=None, payload=None
)
assert models.User.objects.get() == user
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the identity when they change.
The email on the user should not be changed.
"""
klass = OIDCAuthenticationBackend()
identity = IdentityFactory(name="John Doe", email="john.doe@example.com")
user_email = identity.user.email
# Create multiple identities for a user
for _ in range(5):
IdentityFactory(user=identity.user)
assert models.User.objects.count() == 1
new_email = "test@fooo.com"
def get_userinfo_mocked(*args):
return {
"sub": identity.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
}
return {"sub": identity.sub, "email": new_email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
# Additional update query if the email has changed
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
access_token=AccessToken(), id_token=None, payload=None
)
identity.refresh_from_db()
assert identity.email == email
assert identity.name == f"{first_name:s} {last_name:s}"
assert identity.email == new_email
assert models.User.objects.count() == 1
assert user == identity.user
@@ -141,7 +105,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
access_token=AccessToken(), id_token=None, payload=None
)
identity = user.identities.get()
@@ -156,28 +120,26 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
def test_authentication_getter_new_user_with_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's email and name should be set on the identity.
The "email" field on the User model should not be set as it is reserved for staff users.
User's info contains an email, created user's email should be set.
"""
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
access_token=AccessToken(), id_token=None, payload=None
)
identity = user.identities.get()
assert identity.sub == "123"
assert identity.email == email
assert identity.name == "John Doe"
assert user.email is None
assert user.email == email
assert models.User.objects.count() == 1
@@ -193,9 +155,10 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(0), pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
InvalidToken, match="User info contained no recognizable user identification"
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
user = klass.get_or_create_user(
access_token=AccessToken(), id_token=None, payload=None
)
assert models.User.objects.exists() is False
@@ -70,19 +70,19 @@ def test_models_identities_is_main_switch():
def test_models_identities_email_not_required():
"""The 'email' field can be blank."""
"""The "email" field can be blank."""
user = factories.UserFactory()
models.Identity.objects.create(user=user, sub="123", email=None)
def test_models_identities_user_required():
"""The 'user' field is required."""
"""The "user" field is required."""
with pytest.raises(models.User.DoesNotExist, match="Identity has no user."):
models.Identity.objects.create(user=None, email="david@example.com")
def test_models_identities_email_unique_same_user():
"""The 'email' field should be unique for a given user."""
"""The "email" field should be unique for a given user."""
email = factories.IdentityFactory()
with pytest.raises(
@@ -93,13 +93,13 @@ def test_models_identities_email_unique_same_user():
def test_models_identities_email_unique_different_users():
"""The 'email' field should not be unique among users."""
"""The "email" field should not be unique among users."""
email = factories.IdentityFactory()
factories.IdentityFactory(email=email.email)
def test_models_identities_email_normalization():
"""The 'email' field should be automatically normalized upon saving."""
"""The email field should be automatically normalized upon saving."""
email = factories.IdentityFactory()
email.email = "Thomas.Jefferson@Example.com"
email.save()
@@ -120,21 +120,21 @@ def test_models_identities_ordering():
def test_models_identities_sub_null():
"""The 'sub' field should not be null."""
"""The "sub" field should not be null."""
user = factories.UserFactory()
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Identity.objects.create(user=user, sub=None)
def test_models_identities_sub_blank():
"""The 'sub' field should not be blank."""
"""The "sub" field should not be blank."""
user = factories.UserFactory()
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Identity.objects.create(user=user, email="david@example.com", sub="")
def test_models_identities_sub_unique():
"""The 'sub' field should be unique."""
"""The "sub" field should be unique."""
user = factories.UserFactory()
identity = factories.IdentityFactory()
with pytest.raises(ValidationError, match="Identity with this Sub already exists."):
@@ -142,7 +142,7 @@ def test_models_identities_sub_unique():
def test_models_identities_sub_max_length():
"""The 'sub' field should be 255 characters maximum."""
"""The subfield should be 255 characters maximum."""
factories.IdentityFactory(sub="a" * 255)
with pytest.raises(ValidationError) as excinfo:
factories.IdentityFactory(sub="a" * 256)
@@ -154,13 +154,13 @@ def test_models_identities_sub_max_length():
def test_models_identities_sub_special_characters():
"""The 'sub' field should accept periods, dashes, +, @ and underscores."""
"""The subfield should accept periods, dashes, +, @ and underscores."""
identity = factories.IdentityFactory(sub="dave.bowman-1+2@hal_9000")
assert identity.sub == "dave.bowman-1+2@hal_9000"
def test_models_identities_sub_spaces():
"""The 'sub' field should not accept spaces."""
"""The subfield should not accept spaces."""
with pytest.raises(ValidationError) as excinfo:
factories.IdentityFactory(sub="a b")
@@ -171,12 +171,12 @@ def test_models_identities_sub_spaces():
def test_models_identities_sub_upper_case():
"""The 'sub' field should accept upper case characters."""
"""The subfield should accept upper case characters."""
identity = factories.IdentityFactory(sub="John")
assert identity.sub == "John"
def test_models_identities_sub_ascii():
"""The 'sub' field should accept non ASCII letters."""
"""The subfield should accept non ASCII letters."""
identity = factories.IdentityFactory(sub="rené")
assert identity.sub == "rené"
@@ -1,46 +0,0 @@
"""
Unit tests for the Invitation model
"""
from django.core.exceptions import ValidationError
import pytest
from core import factories
pytestmark = pytest.mark.django_db
def test_models_invitations_email_no_empty_mail():
"""The "email" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(email="")
def test_models_invitations_email_no_null_mail():
"""The "email" field is required."""
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.InvitationFactory(email=None)
def test_models_invitations_team_required():
"""The "team" field is required."""
with pytest.raises(ValidationError, match="This field cannot be null"):
factories.InvitationFactory(team=None)
def test_models_invitations_team_should_be_team_instance():
"""The "team" field should be a team instance."""
with pytest.raises(ValueError, match='Invitation.team" must be a "Team" instance'):
factories.InvitationFactory(team="ee")
def test_models_invitations_role_required():
"""The "role" field is required."""
with pytest.raises(ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(role="")
def test_models_invitations_role_among_choices():
"""The "role" field should be a valid choice."""
with pytest.raises(ValidationError, match="Value 'boss' is not a valid choice"):
factories.InvitationFactory(role="boss")
+25
View File
@@ -0,0 +1,25 @@
"""Utils for tests in the People core application"""
from rest_framework_simplejwt.tokens import AccessToken
class OIDCToken(AccessToken):
"""Set payload on token from user/contact/email"""
@classmethod
def for_user(cls, user):
"""Returns an authorization token for the given user for testing."""
identity = user.identities.filter(is_main=True).first()
token = cls()
token["first_name"] = (
user.profile_contact.short_name if user.profile_contact else "David"
)
token["last_name"] = (
" ".join(user.profile_contact.full_name.split()[1:])
if user.profile_contact
else "Bowman"
)
token["sub"] = str(identity.sub)
token["email"] = user.email
return token
+10
View File
@@ -0,0 +1,10 @@
"""Tokens for People's core app."""
from rest_framework_simplejwt.settings import api_settings
from rest_framework_simplejwt.tokens import Token
class BearerToken(Token):
"""Bearer token as emitted by Keycloak OIDC for example."""
token_type = "Bearer" # noqa: S105
lifetime = api_settings.ACCESS_TOKEN_LIFETIME
@@ -11,14 +11,10 @@ from django import db
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from faker import Faker
from core import models
from demo import defaults
fake = Faker()
logger = logging.getLogger("people.commands.demo.create_demo")
@@ -138,7 +134,6 @@ def create_demo(stdout):
sub=uuid4(),
email=f"identity{i:d}{user_email:s}",
is_main=(i == 0),
name=fake.name(),
)
)
queue.flush()
+4 -25
View File
@@ -228,18 +228,7 @@ class Base(Configuration):
"PAGE_SIZE": 20,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_RATES": {
"sustained": values.Value(
default="150/hour",
environ_name="SUSTAINED_THROTTLE_RATES",
environ_prefix=None,
),
"burst": values.Value(
default="20/minute",
environ_name="BURST_THROTTLE_RATES",
environ_prefix=None,
),
},
"DEFAULT_THROTTLE_RATES": {"sustained": "150/hour", "burst": "20/minute"},
}
SPECTACULAR_SETTINGS = {
@@ -268,12 +257,11 @@ class Base(Configuration):
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGINS = values.ListValue([], environ_name="CORS_ALLOWED_ORIGINS")
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
@@ -304,8 +292,7 @@ class Base(Configuration):
"people", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
)
OIDC_RP_CLIENT_SECRET = values.Value(
environ_name="OIDC_RP_CLIENT_SECRET",
environ_prefix=None,
environ_name="OIDC_RP_CLIENT_SECRET", environ_prefix=None
)
OIDC_OP_JWKS_ENDPOINT = values.Value(
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
@@ -344,12 +331,6 @@ class Base(Configuration):
default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None
)
USER_OIDC_FIELDS_TO_NAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_NAME",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -430,7 +411,7 @@ class Development(Base):
ALLOWED_HOSTS = ["*"]
CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"]
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072"]
DEBUG = True
SESSION_COOKIE_NAME = "people_sessionid"
@@ -577,8 +558,6 @@ class Demo(Production):
nota bene: it should inherit from the Production environment.
"""
CSRF_TRUSTED_ORIGINS = ["http://localhost:8082"]
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
+10 -9
View File
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.34.51",
"boto3==1.34.39",
"Brotli==1.1.0",
"celery[redis]==5.3.6",
"django-configurations==2.5",
@@ -35,9 +35,10 @@ dependencies = [
"django-storages==1.14.2",
"django-timezone-field>=5.1",
"django==5.0.2",
"djangorestframework-simplejwt[crypto]==5.3.1",
"djangorestframework==3.14.0",
"drf_spectacular==0.27.1",
"dockerflow==2024.2.0",
"dockerflow==2024.1.0",
"easy_thumbnails==2.8.5",
"factory_boy==3.3.0",
"gunicorn==21.2.0",
@@ -46,7 +47,7 @@ dependencies = [
"psycopg[binary]==3.1.18",
"PyJWT==2.8.0",
"requests==2.31.0",
"sentry-sdk==1.40.6",
"sentry-sdk==1.40.3",
"url-normalize==1.4.3",
"whitenoise==6.6.0",
"mozilla-django-oidc==4.0.0",
@@ -63,18 +64,18 @@ dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.2.1",
"ipdb==0.13.13",
"ipython==8.22.1",
"ipython==8.21.0",
"pyfakefs==5.3.5",
"pylint-django==2.5.5",
"pylint==3.1.0",
"pylint==3.0.3",
"pytest-cov==4.1.0",
"pytest-django==4.8.0",
"pytest==8.0.2",
"pytest==8.0.0",
"pytest-icdiff==0.9",
"pytest-xdist==3.5.0",
"responses==0.25.0",
"ruff==0.2.2",
"types-requests==2.31.0.20240218",
"responses==0.24.1",
"ruff==0.2.1",
"types-requests==2.31.0.20240125",
]
[tool.setuptools]
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://kubernetes-service-name-wip:8071/api/v1.0/
+17 -71
View File
@@ -3,8 +3,6 @@ const config = {
default: {
theme: {
colors: {
'card-border': '#DDDDDD',
'primary-bg': '#FAFAFA',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
'info-150': '#E5EEFA',
@@ -78,7 +76,6 @@ const config = {
'forms-labelledbox': {
'label-color': {
small: 'var(--c--theme--colors--primary-500)',
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
big: {
disabled: 'var(--c--theme--colors--greyscale-400)',
},
@@ -86,8 +83,6 @@ const config = {
},
'forms-select': {
'border-color': 'var(--c--theme--colors--primary-500)',
'border-color-disabled-hover':
'var(--c--theme--colors--greyscale-200)',
'border-radius': {
hover: 'var(--c--components--forms-select--border-radius)',
focus: 'var(--c--components--forms-select--border-radius)',
@@ -180,7 +175,6 @@ const config = {
dsfr: {
theme: {
colors: {
'card-border': '#DDDDDD',
'primary-text': '#000091',
'primary-100': '#f5f5fe',
'primary-200': '#ececfe',
@@ -201,11 +195,10 @@ const config = {
'secondary-700': '#3b2424',
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-000': '#cecece',
'greyscale-100': '#f6f6f6',
'greyscale-200': '#eeeeee',
'greyscale-300': '#e5e5e5',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
@@ -265,77 +258,30 @@ const config = {
'border-radius': '0',
},
button: {
'border-radius': '4px',
primary: {
background: {
color: 'var(--c--theme--colors--primary-text)',
'color-hover': 'var(--c--theme--colors--primary-700)',
'color-active': 'var(--c--theme--colors--primary-900)',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
secondary: {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-active': 'var(--c--theme--colors--primary-200)',
},
border: {
'color-hover': 'var(--c--theme--colors--primary-300)',
},
color: 'var(--c--theme--colors--primary-text)',
},
},
datagrid: {
header: {
color: 'var(--c--theme--colors--primary-text)',
size: 'var(--c--theme--font--sizes--s)',
},
body: {
'background-color': 'transparent',
'background-color-hover': '#F4F4FD',
},
pagination: {
'background-color': 'transparent',
'background-color-active': 'var(--c--theme--colors--primary-300)',
},
'border-radius': '2px',
},
'forms-checkbox': {
'border-radius': '0',
},
'forms-datepicker': {
'border-radius': '0',
},
'forms-fileuploader': {
'border-radius': '0',
},
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
},
'forms-labelledbox': {
'label-color': {
big: 'var(--c--theme--colors--primary-text)',
},
},
'forms-select': {
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
},
'forms-switch': {
'handle-border-radius': '2px',
'rail-border-radius': '4px',
},
'forms-input': {
'border-radius': '0',
},
'forms-select': {
'border-radius': '0',
},
'forms-datepicker': {
'border-radius': '0',
},
'forms-textarea': {
'border-radius': '0',
},
'forms-fileuploader': {
'border-radius': '0',
},
},
},
},
-7
View File
@@ -1,13 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
unoptimized: true,
},
compiler: {
// Enables the styled-components SWC transform
styledComponents: true,
},
webpack(config) {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
+9 -11
View File
@@ -16,27 +16,25 @@
},
"dependencies": {
"@openfun/cunningham-react": "2.4.0",
"@tanstack/react-query": "5.24.8",
"i18next": "23.10.0",
"luxon": "3.4.4",
"next": "14.1.1",
"@tanstack/react-query": "5.18.1",
"i18next": "23.7.16",
"next": "14.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-i18next": "14.0.5",
"react-i18next": "14.0.0",
"styled-components": "6.1.8",
"zustand": "4.5.2"
"zustand": "4.5.0"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.24.8",
"@testing-library/jest-dom": "6.4.2",
"@tanstack/react-query-devtools": "5.18.1",
"@testing-library/jest-dom": "6.4.1",
"@testing-library/react": "14.2.1",
"@types/jest": "29.5.12",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.2.61",
"@types/react": "18.2.53",
"@types/react-dom": "18.2.18",
"dotenv": "16.4.5",
"dotenv": "16.4.1",
"eslint-config-people": "*",
"fetch-mock": "9.11.0",
"jest": "29.7.0",
@@ -1,17 +0,0 @@
interface IAPIError {
status: number;
cause?: string[];
}
export class APIError extends Error implements IAPIError {
public status: number;
public cause?: string[];
constructor(message: string, { status, cause }: IAPIError) {
super(message);
this.name = 'APIError';
this.status = status;
this.cause = cause;
}
}
@@ -1,7 +1,7 @@
import fetchMock from 'fetch-mock';
import { fetchAPI } from '@/api';
import { useAuthStore } from '@/features/auth';
import { useAuthStore } from '@/features/';
describe('fetchAPI', () => {
beforeEach(() => {
@@ -34,25 +34,10 @@ describe('fetchAPI', () => {
});
it('logout if 401 response', async () => {
const mockReplace = jest.fn();
Object.defineProperty(window, 'location', {
configurable: true,
enumerable: true,
value: {
replace: mockReplace,
},
});
useAuthStore.setState({ userData: { email: 'test@test.com' } });
fetchMock.mock('http://some.api.url/api/v1.0/some/url', 401);
await fetchAPI('some/url');
expect(useAuthStore.getState().userData).toBeUndefined();
expect(mockReplace).toHaveBeenCalledWith(
'http://some.api.url/api/v1.0/authenticate/',
);
});
});
+2 -22
View File
@@ -1,41 +1,21 @@
import { login, useAuthStore } from '@/features/auth';
/**
* Retrieves the CSRF token from the document's cookies.
*
* @returns {string|null} The CSRF token if found in the cookies, or null if not present.
*/
function getCSRFToken() {
return document.cookie
.split(';')
.filter((cookie) => cookie.trim().startsWith('csrftoken='))
.map((cookie) => cookie.split('=')[1])
.pop();
}
import { useAuthStore } from '@/features/';
export const fetchAPI = async (input: string, init?: RequestInit) => {
const apiUrl = `${process.env.NEXT_PUBLIC_API_URL}${input}`;
const { logout } = useAuthStore.getState();
const csrfToken = getCSRFToken();
const response = await fetch(apiUrl, {
...init,
credentials: 'include',
headers: {
...init?.headers,
'Content-Type': 'application/json',
...(csrfToken && { 'X-CSRFToken': csrfToken }),
},
});
// todo - handle 401, redirect to login screen
// todo - please have a look to this documentation page https://mozilla-django-oidc.readthedocs.io/en/stable/xhr.html
if (response.status === 401) {
logout();
// Fix - force re-logging the user, will be refactored
login();
}
response.status === 401 && logout();
return response;
};
-2
View File
@@ -1,4 +1,2 @@
export * from './APIError';
export * from './fetchApi';
export * from './types';
export * from './utils';
-17
View File
@@ -1,17 +0,0 @@
export const errorCauses = async (response: Response) => {
const errorsBody = (await response.json()) as Record<
string,
string | string[]
> | null;
const causes = errorsBody
? Object.entries(errorsBody)
.map(([, value]) => value)
.flat()
: undefined;
return {
status: response.status,
cause: causes,
};
};
@@ -0,0 +1,25 @@
import { Box } from '@/components';
import { HEADER_HEIGHT, Header, Menu } from '@/features/';
export default function InnerLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Box as="main" $direction="column" $height="100vh" $css="overflow:hidden;">
<Header />
<Box $css="flex: 1;">
<Menu />
<Box
$direction="column"
$height={`calc(100vh - ${HEADER_HEIGHT})`}
$width="100%"
$css="overflow: auto;"
>
{children}
</Box>
</Box>
</Box>
);
}
@@ -3,16 +3,16 @@ import { render, screen } from '@testing-library/react';
import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
import Page from '../page';
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });
expect(
screen.getByRole('button', {
name: /Create a new team/i,
}),
).toBeInTheDocument();
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
'Hello Desk !',
);
});
});
@@ -0,0 +1,7 @@
'use client';
import { Box } from '@/components';
export default function Contacts() {
return <Box>Contacts</Box>;
}

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,7 @@
'use client';
import { Box } from '@/components';
export default function Favorite() {
return <Box>Favorite</Box>;
}
@@ -0,0 +1,6 @@
@import url('../cunningham/cunningham-style.css');
body {
margin: 0;
padding: 0;
}
@@ -0,0 +1,7 @@
'use client';
import { Box } from '@/components';
export default function Groups() {
return <Box>Groups</Box>;
}
@@ -0,0 +1,7 @@
'use client';
import { Box } from '@/components';
export default function Help() {
return <Box>Help</Box>;
}
+37
View File
@@ -0,0 +1,37 @@
'use client';
import { CunninghamProvider } from '@openfun/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useCunninghamTheme } from '@/cunningham';
import { Auth } from '@/features/auth/Auth';
import '@/i18n/initI18n';
import InnerLayout from './InnerLayout';
import './globals.css';
const queryClient = new QueryClient();
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const { theme } = useCunninghamTheme();
return (
<html lang="en">
<body suppressHydrationWarning={process.env.NODE_ENV === 'development'}>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<CunninghamProvider theme={theme}>
<Auth>
<InnerLayout>{children}</InnerLayout>
</Auth>
</CunninghamProvider>
</QueryClientProvider>
</body>
</html>
);
}
+17
View File
@@ -0,0 +1,17 @@
'use client';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { Teams } from '@/features';
export default function Home() {
const { t } = useTranslation();
return (
<Box $direction="column" className="p-b">
<h1>{t('Hello Desk !')}</h1>
<Teams />
</Box>
);
}
@@ -0,0 +1,7 @@
'use client';
import { Box } from '@/components';
export default function Recent() {
return <Box>Recent</Box>;
}
@@ -1,29 +0,0 @@
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_179_19260)">
<path
d="M12.6406 26.02C14.5606 26.06 16.3406 27.02 17.5406 28.7C19.0006 30.76 21.4206 32 24.0006 32C26.5806 32 29.0006 30.76 30.4606 28.68C31.6606 27 33.4406 26.04 35.3606 26C33.9206 23.56 28.1606 22 24.0006 22C19.8606 22 14.0806 23.56 12.6406 26.02Z"
fill="currentColor"
/>
<path
d="M8 26C11.32 26 14 23.32 14 20C14 16.68 11.32 14 8 14C4.68 14 2 16.68 2 20C2 23.32 4.68 26 8 26Z"
fill="currentColor"
/>
<path
d="M40 26C43.32 26 46 23.32 46 20C46 16.68 43.32 14 40 14C36.68 14 34 16.68 34 20C34 23.32 36.68 26 40 26Z"
fill="currentColor"
/>
<path
d="M24 20C27.32 20 30 17.32 30 14C30 10.68 27.32 8 24 8C20.68 8 18 10.68 18 14C18 17.32 20.68 20 24 20Z"
fill="currentColor"
/>
<path
d="M42 28H35.46C33.92 28 32.76 28.9 32.1 29.84C32.02 29.96 29.38 34 24 34C21.14 34 17.94 32.72 15.9 29.84C15.12 28.74 13.9 28 12.54 28H6C3.8 28 2 29.8 2 32V40H16V35.48C18.3 37.08 21.08 38 24 38C26.92 38 29.7 37.08 32 35.48V40H46V32C46 29.8 44.2 28 42 28Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_179_19260">
<rect width="48" height="48" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,6 +0,0 @@
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M20 0C8.96 0 0 8.96 0 20C0 31.04 8.96 40 20 40C31.04 40 40 31.04 40 20C40 8.96 31.04 0 20 0ZM20 6C23.32 6 26 8.68 26 12C26 15.32 23.32 18 20 18C16.68 18 14 15.32 14 12C14 8.68 16.68 6 20 6ZM20 34.4C15 34.4 10.58 31.84 8 27.96C8.06 23.98 16 21.8 20 21.8C23.98 21.8 31.94 23.98 32 27.96C29.42 31.84 25 34.4 20 34.4Z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 439 B

+1 -21
View File
@@ -1,9 +1,7 @@
import { ComponentPropsWithRef, ReactHTML } from 'react';
import { ReactHTML } from 'react';
import styled from 'styled-components';
import { CSSProperties } from 'styled-components/dist/types';
import { hideEffect, showEffect } from './Effect';
export interface BoxProps {
as?: keyof ReactHTML;
$align?: CSSProperties['alignItems'];
@@ -12,24 +10,17 @@ export interface BoxProps {
$css?: string;
$direction?: CSSProperties['flexDirection'];
$display?: CSSProperties['display'];
$effect?: 'show' | 'hide';
$flex?: boolean;
$gap?: CSSProperties['gap'];
$height?: CSSProperties['height'];
$justify?: CSSProperties['justifyContent'];
$overflow?: CSSProperties['overflow'];
$position?: CSSProperties['position'];
$radius?: CSSProperties['borderRadius'];
$width?: CSSProperties['width'];
$maxWidth?: CSSProperties['maxWidth'];
$minWidth?: CSSProperties['minWidth'];
}
export type BoxType = ComponentPropsWithRef<typeof Box>;
export const Box = styled('div')<BoxProps>`
display: flex;
flex-direction: column;
${({ $align }) => $align && `align-items: ${$align};`}
${({ $background }) => $background && `background: ${$background};`}
${({ $color }) => $color && `color: ${$color};`}
@@ -39,19 +30,8 @@ export const Box = styled('div')<BoxProps>`
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
${({ $justify }) => $justify && `justify-content: ${$justify};`}
${({ $overflow }) => $overflow && `overflow: ${$overflow};`}
${({ $position }) => $position && `position: ${$position};`}
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
${({ $css }) => $css && `${$css};`}
${({ $effect }) => {
switch ($effect) {
case 'show':
return showEffect;
case 'hide':
return hideEffect;
}
}}
`;
@@ -1,28 +0,0 @@
import { PropsWithChildren } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { Box, BoxType } from '.';
export const Card = ({
children,
$css,
...props
}: PropsWithChildren<BoxType>) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$background="white"
$radius="4px"
$css={`
box-shadow: 2px 2px 5px ${colorsTokens()['primary-300']}88;
border: 1px solid ${colorsTokens()['card-border']};
${$css}
`}
{...props}
>
{children}
</Box>
);
};
@@ -1,20 +0,0 @@
import { css, keyframes } from 'styled-components';
const show = keyframes`
0% { transform: scaleY(0); opacity: 0; }
100% { transform: scaleY(1); opacity: 1;}
`;
const hide = keyframes`
0% { transform: scaleY(1); opacity: 1;}
100% { display:none; transform: scaleY(0); opacity: 0 }
`;
export const showEffect = css`
animation: ${show} 0.3s ease-in-out;
`;
export const hideEffect = css`
animation: ${hide} 0.3s ease-in-out;
animation-fill-mode: forwards;
`;
@@ -1,55 +0,0 @@
import { PropsWithChildren, useEffect, useRef } from 'react';
import { Box, BoxType } from '@/components';
interface InfiniteScrollProps extends BoxType {
hasMore: boolean;
isLoading: boolean;
next: () => void;
scrollContainer: HTMLElement | null;
}
export const InfiniteScroll = ({
children,
hasMore,
isLoading,
next,
scrollContainer,
...boxProps
}: PropsWithChildren<InfiniteScrollProps>) => {
const timeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!scrollContainer) {
return;
}
const nextHandle = () => {
if (!hasMore || isLoading) {
return;
}
// To not wait until the end of the scroll to load more data
const heightFromBottom = 150;
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
if (scrollTop + clientHeight >= scrollHeight - heightFromBottom) {
next();
}
};
const handleScroll = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(nextHandle, 50);
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, [hasMore, isLoading, next, scrollContainer]);
return <Box {...boxProps}>{children}</Box>;
};
@@ -1,11 +0,0 @@
import Link from 'next/link';
import styled from 'styled-components';
export const StyledLink = styled(Link)`
text-decoration: none;
color: #ffffff33;
&[aria-current='page'] {
color: #ffffff;
}
display: flex;
`;
@@ -14,7 +14,6 @@ export interface TextProps extends BoxProps {
'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
>;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
// eslint-disable-next-line @typescript-eslint/ban-types
$size?: TextSizes | (string & {});
$theme?:
@@ -38,10 +37,7 @@ export interface TextProps extends BoxProps {
| '900';
}
export type TextType = ComponentPropsWithRef<typeof Text>;
export const TextStyled = styled(Box)<TextProps>`
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
${({ $weight }) => $weight && `font-weight: ${$weight};`}
${({ $size }) =>
$size &&
@@ -57,7 +53,7 @@ export const Text = ({
return (
<TextStyled
as="span"
$theme="greyscale"
$theme="primary"
$variation="text"
{...props}
></TextStyled>
@@ -1,62 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextType } from '@/components';
interface TextErrorsProps extends TextType {
causes?: string[];
defaultMessage?: string;
duration?: number;
}
/**
* Display a list of errors
* @param causes - List of causes
* @param defaultMessage - Default message if no causes
* @param duration -- Duration in milliseconds of the error message
* - Set to `0` or `undefined` for unlimited duration
* @param textProps - Text props
*/
export const TextErrors = ({
causes,
defaultMessage,
duration = 6000, // 6 seconds
...textProps
}: TextErrorsProps) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(true);
if (duration) {
setTimeout(() => {
setVisible(false);
}, duration);
}
return (
<Box $effect={visible ? 'show' : 'hide'}>
{causes &&
causes.map((cause, i) => (
<Text
key={`causes-${i}`}
className="mt-s"
$theme="danger"
$textAlign="center"
{...textProps}
>
{cause}
</Text>
))}
{!causes && (
<Text
className="mt-s"
$theme="danger"
$textAlign="center"
{...textProps}
>
{defaultMessage || t('Something bad happens, please retry.')}
</Text>
)}
</Box>
);
};
@@ -1,5 +1,2 @@
export * from './Box';
export * from './Text';
export * from './Link';
export * from './TextErrors';
export * from './Card';
@@ -25,4 +25,7 @@
--c--components--forms-select--value-color--disabled: var(
--c--theme--colors--greyscale-400
);
--c--components--forms-labelledbox--label-color--big: var(
--c--theme--colors--primary-500
);
}
@@ -17,11 +17,11 @@
}
.labelled-box label {
color: var(--c--theme--colors--primary-text);
color: var(--c--theme--colors--primary-500);
}
.labelled-box--disabled label {
color: var(--c--components--forms-labelledbox--label-color--small-disabled);
color: var(--c--components--forms-labelledbox--label-color--small--disabled);
}
.c__field :not(.c__textarea__wrapper, div) .labelled-box label.placeholder {
@@ -53,7 +53,7 @@
.c__input__wrapper:hover,
.c__textarea__wrapper:hover {
box-shadow: var(--c--components--forms-input--box-shadow-color) 0 0 0 2px;
box-shadow: var(--c--theme--colors--primary-500) 0 0 0 2px;
}
.c__textarea__wrapper--disabled:hover,
@@ -120,16 +120,7 @@ input:-webkit-autofill:focus {
}
.c__select:not(.c__select--disabled) .c__select__wrapper:hover {
box-shadow: var(--c--components--forms-input--box-shadow-color) 0 0 0 2px;
}
.c__select__wrapper:hover {
border-radius: var(--c--components--forms-select--border-radius-hover);
border-color: var(--c--components--forms-select--border-color-hover);
}
.c__select--disabled .c__select__wrapper:hover {
border-color: var(--c--components--forms-select--border-color-disabled-hover);
box-shadow: var(--c--theme--colors--primary-500) 0 0 0 2px;
}
.c__select__menu__item {
@@ -144,7 +135,7 @@ input:-webkit-autofill:focus {
}
.c__select__wrapper:focus-within .labelled-box--disabled label {
color: var(--c--components--forms-labelledbox--label-color--small-disabled);
color: var(--c--components--forms-labelledbox--label-color--small--disabled);
}
.c__select__wrapper .labelled-box {
@@ -182,60 +173,28 @@ input:-webkit-autofill:focus {
/**
* DataGrid
*/
.c__datagrid__table__container {
overflow: auto;
.c__datagrid > table td {
max-width: 10rem;
white-space: normal;
color: var(--c--components--datagrid--cell--color);
font-size: var(--c--components--datagrid--cell--size);
}
.c__datagrid__table__container > table th .c__datagrid__header {
color: var(--c--components--datagrid--header--color);
.c__datagrid > table th .c__datagrid__header {
color: var(--c--theme--colors--primary-500);
font-weight: var(--c--components--datagrid--header--weight);
font-size: var(--c--components--datagrid--header--size);
padding-block: 2rem;
}
.c__datagrid__table__container > table tbody tr {
border: none;
border-top: 1px var(--c--theme--colors--greyscale-300) solid;
border-bottom: 1px var(--c--theme--colors--greyscale-300) solid;
}
.c__datagrid__table__container > table tbody {
background-color: var(--c--components--datagrid--body--background-color);
}
.c__datagrid__table__container > table tbody tr:hover {
background-color: var(
--c--components--datagrid--body--background-color-hover
);
}
.c__datagrid__table__container > table th:first-child,
.c__datagrid__table__container > table td:first-child {
padding-left: 2rem;
.c__datagrid > table tbody tr {
border: 1px var(--c--theme--colors--primary-100) solid;
}
.c__datagrid > .c__pagination {
padding-right: 1rem;
padding-top: 1rem;
justify-content: flex-end;
}
.c__pagination__list {
gap: 3px;
border-radius: 4px;
background: var(--c--components--datagrid--pagination--background-color);
}
.c__pagination__list .c__button--tertiary.c__button--active {
background-color: var(
--c--components--datagrid--pagination--background-color-active
);
color: var(--c--theme--colors--greyscale-800);
}
.c__pagination__list .c__button--tertiary:disabled {
display: none;
}
@media (width <= 380px) {
.c__datagrid > .c__pagination {
flex-direction: column;
@@ -313,13 +272,6 @@ input:-webkit-autofill:focus {
color: var(--c--components--button--primary--color);
}
.c__button--primary:hover {
background-color: var(
--c--components--button--primary--background--color-hover
);
color: var(--c--components--button--primary--color-hover);
}
.c__button--primary:active,
.c__button--primary.c__button--active {
background-color: var(
@@ -366,7 +318,6 @@ input:-webkit-autofill:focus {
--c--components--button--secondary--background--color-hover
);
color: var(--c--components--button--secondary--color-hover);
border: 1px solid var(--c--components--button--secondary--border--color-hover);
}
.c__button--tertiary {
@@ -69,8 +69,6 @@
--c--theme--colors--success-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--warning-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--danger-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--card-border: #ddd;
--c--theme--colors--primary-bg: #fafafa;
--c--theme--colors--primary-150: #e5eefa;
--c--theme--colors--info-150: #e5eefa;
--c--theme--font--sizes--h1: 2.2rem;
@@ -160,18 +158,12 @@
--c--components--forms-labelledbox--label-color--small: var(
--c--theme--colors--primary-500
);
--c--components--forms-labelledbox--label-color--small-disabled: var(
--c--theme--colors--greyscale-400
);
--c--components--forms-labelledbox--label-color--big--disabled: var(
--c--theme--colors--greyscale-400
);
--c--components--forms-select--border-color: var(
--c--theme--colors--primary-500
);
--c--components--forms-select--border-color-disabled-hover: var(
--c--theme--colors--greyscale-200
);
--c--components--forms-select--border-radius--hover: var(
--c--components--forms-select--border-radius
);
@@ -321,7 +313,6 @@
}
.cunningham-theme--dsfr {
--c--theme--colors--card-border: #ddd;
--c--theme--colors--primary-text: #000091;
--c--theme--colors--primary-100: #f5f5fe;
--c--theme--colors--primary-200: #ececfe;
@@ -342,11 +333,10 @@
--c--theme--colors--secondary-700: #3b2424;
--c--theme--colors--secondary-800: #341f1f;
--c--theme--colors--secondary-900: #2b1919;
--c--theme--colors--greyscale-text: #303c4b;
--c--theme--colors--greyscale-000: #f6f6f6;
--c--theme--colors--greyscale-100: #eee;
--c--theme--colors--greyscale-200: #e5e5e5;
--c--theme--colors--greyscale-300: #e1e1e1;
--c--theme--colors--greyscale-000: #cecece;
--c--theme--colors--greyscale-100: #f6f6f6;
--c--theme--colors--greyscale-200: #eee;
--c--theme--colors--greyscale-300: #e5e5e5;
--c--theme--colors--greyscale-400: #ddd;
--c--theme--colors--greyscale-500: #cecece;
--c--theme--colors--greyscale-600: #7b7b7b;
@@ -396,70 +386,15 @@
--c--theme--font--families--accent: marianne;
--c--theme--font--families--base: marianne;
--c--components--alert--border-radius: 0;
--c--components--button--border-radius: 4px;
--c--components--button--primary--background--color: var(
--c--theme--colors--primary-text
);
--c--components--button--primary--background--color-hover: var(
--c--theme--colors--primary-700
);
--c--components--button--primary--background--color-active: var(
--c--theme--colors--primary-900
);
--c--components--button--primary--color: #fff;
--c--components--button--primary--color-hover: #fff;
--c--components--button--primary--color-active: #fff;
--c--components--button--secondary--background--color-hover: var(
--c--theme--colors--primary-100
);
--c--components--button--secondary--background--color-active: var(
--c--theme--colors--primary-200
);
--c--components--button--secondary--border--color-hover: var(
--c--theme--colors--primary-300
);
--c--components--button--secondary--color: var(
--c--theme--colors--primary-text
);
--c--components--datagrid--header--color: var(
--c--theme--colors--primary-text
);
--c--components--datagrid--header--size: var(--c--theme--font--sizes--s);
--c--components--datagrid--body--background-color: transparent;
--c--components--datagrid--body--background-color-hover: #f4f4fd;
--c--components--datagrid--pagination--background-color: transparent;
--c--components--datagrid--pagination--background-color-active: var(
--c--theme--colors--primary-300
);
--c--components--button--border-radius: 2px;
--c--components--forms-checkbox--border-radius: 0;
--c--components--forms-datepicker--border-radius: 0;
--c--components--forms-fileuploader--border-radius: 0;
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--background-color: #fff;
--c--components--forms-input--border-color: var(
--c--theme--colors--primary-text
);
--c--components--forms-input--box-shadow-color: var(
--c--theme--colors--primary-text
);
--c--components--forms-labelledbox--label-color--big: var(
--c--theme--colors--primary-text
);
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius-hover: 4px;
--c--components--forms-select--background-color: #fff;
--c--components--forms-select--border-color: var(
--c--theme--colors--primary-text
);
--c--components--forms-select--border-color-hover: var(
--c--theme--colors--primary-text
);
--c--components--forms-select--box-shadow-color: var(
--c--theme--colors--primary-text
);
--c--components--forms-switch--handle-border-radius: 2px;
--c--components--forms-switch--rail-border-radius: 4px;
--c--components--forms-input--border-radius: 0;
--c--components--forms-select--border-radius: 0;
--c--components--forms-datepicker--border-radius: 0;
--c--components--forms-textarea--border-radius: 0;
--c--components--forms-fileuploader--border-radius: 0;
}
.clr-secondary-text {
@@ -742,14 +677,6 @@
color: var(--c--theme--colors--danger-text);
}
.clr-card-border {
color: var(--c--theme--colors--card-border);
}
.clr-primary-bg {
color: var(--c--theme--colors--primary-bg);
}
.clr-primary-150 {
color: var(--c--theme--colors--primary-150);
}
@@ -1038,14 +965,6 @@
background-color: var(--c--theme--colors--danger-text);
}
.bg-card-border {
background-color: var(--c--theme--colors--card-border);
}
.bg-primary-bg {
background-color: var(--c--theme--colors--primary-bg);
}
.bg-primary-150 {
background-color: var(--c--theme--colors--primary-150);
}
@@ -73,8 +73,6 @@ export const tokens = {
'success-text': '#FFFFFF',
'warning-text': '#FFFFFF',
'danger-text': '#FFFFFF',
'card-border': '#DDDDDD',
'primary-bg': '#FAFAFA',
'primary-150': '#E5EEFA',
'info-150': '#E5EEFA',
},
@@ -183,14 +181,11 @@ export const tokens = {
'forms-labelledbox': {
'label-color': {
small: 'var(--c--theme--colors--primary-500)',
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
big: { disabled: 'var(--c--theme--colors--greyscale-400)' },
},
},
'forms-select': {
'border-color': 'var(--c--theme--colors--primary-500)',
'border-color-disabled-hover':
'var(--c--theme--colors--greyscale-200)',
'border-radius': {
hover: 'var(--c--components--forms-select--border-radius)',
focus: 'var(--c--components--forms-select--border-radius)',
@@ -326,7 +321,6 @@ export const tokens = {
dsfr: {
theme: {
colors: {
'card-border': '#DDDDDD',
'primary-text': '#000091',
'primary-100': '#f5f5fe',
'primary-200': '#ececfe',
@@ -347,11 +341,10 @@ export const tokens = {
'secondary-700': '#3b2424',
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-000': '#cecece',
'greyscale-100': '#f6f6f6',
'greyscale-200': '#eeeeee',
'greyscale-300': '#e5e5e5',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
@@ -403,66 +396,17 @@ export const tokens = {
},
components: {
alert: { 'border-radius': '0' },
button: {
'border-radius': '4px',
primary: {
background: {
color: 'var(--c--theme--colors--primary-text)',
'color-hover': 'var(--c--theme--colors--primary-700)',
'color-active': 'var(--c--theme--colors--primary-900)',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
secondary: {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-active': 'var(--c--theme--colors--primary-200)',
},
border: { 'color-hover': 'var(--c--theme--colors--primary-300)' },
color: 'var(--c--theme--colors--primary-text)',
},
},
datagrid: {
header: {
color: 'var(--c--theme--colors--primary-text)',
size: 'var(--c--theme--font--sizes--s)',
},
body: {
'background-color': 'transparent',
'background-color-hover': '#F4F4FD',
},
pagination: {
'background-color': 'transparent',
'background-color-active': 'var(--c--theme--colors--primary-300)',
},
},
button: { 'border-radius': '2px' },
'forms-checkbox': { 'border-radius': '0' },
'forms-datepicker': { 'border-radius': '0' },
'forms-fileuploader': { 'border-radius': '0' },
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
},
'forms-labelledbox': {
'label-color': { big: 'var(--c--theme--colors--primary-text)' },
},
'forms-select': {
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
},
'forms-switch': {
'handle-border-radius': '2px',
'rail-border-radius': '4px',
},
'forms-input': { 'border-radius': '0' },
'forms-select': { 'border-radius': '0' },
'forms-datepicker': { 'border-radius': '0' },
'forms-textarea': { 'border-radius': '0' },
'forms-fileuploader': { 'border-radius': '0' },
},
},
},
+4
View File
@@ -20,5 +20,9 @@ declare module '*.svg?url' {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_URL?: string;
NEXT_PUBLIC_KEYCLOAK_URL?: string;
NEXT_PUBLIC_KEYCLOAK_REALM?: string;
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID?: string;
NEXT_PUBLIC_KEYCLOAK_LOGIN?: string;
}
}
@@ -6,11 +6,15 @@ import { Box } from '@/components';
import { useAuthStore } from './useAuthStore';
export const Auth = ({ children }: PropsWithChildren) => {
const { authenticated, initAuth } = useAuthStore();
const { initAuth, authenticated, initialized } = useAuthStore();
useEffect(() => {
if (initialized) {
return;
}
initAuth();
}, [initAuth]);
}, [initAuth, initialized]);
if (!authenticated) {
return (
@@ -23,7 +23,7 @@ export interface UserData {
* @returns {Promise<UserData>} A promise that resolves to the user data.
*/
export const getMe = async (): Promise<UserData> => {
const response = await fetchAPI(`users/me/`);
const response = await fetchAPI(`users/me`);
if (!response.ok) {
throw new Error(`Couldn't fetch user data: ${response.statusText}`);
}
@@ -2,38 +2,46 @@ import { create } from 'zustand';
import { UserData, getMe } from '@/features/auth/api';
export const login = () => {
window.location.replace(
new URL('authenticate/', process.env.NEXT_PUBLIC_API_URL).href,
);
};
interface AuthStore {
authenticated: boolean;
initAuth: () => void;
initialized: boolean;
logout: () => void;
userData?: UserData;
}
const initialState = {
authenticated: false,
initialized: false,
userData: undefined,
};
export const useAuthStore = create<AuthStore>((set) => ({
authenticated: initialState.authenticated,
initialized: initialState.initialized,
userData: initialState.userData,
initAuth: () => {
getMe()
.then((data: UserData) => {
set({ authenticated: true, userData: data });
})
.catch(() => {
// todo - implement a proper login screen to prevent automatic navigation.
login();
});
},
initAuth: () =>
set((state) => {
if (state.initialized) {
return {};
}
getMe()
.then((data: UserData) => {
set({ initialized: true, authenticated: true, userData: data });
return { initialized: true };
})
.catch(() => {
// todo - implement a proper login screen to prevent automatic navigation.
window.location.replace(
new URL('authenticate', process.env.NEXT_PUBLIC_API_URL).href,
);
});
return {};
}),
// todo - implement a proper logout to end session
// todo - please follow AC instructions here https://github.com/france-connect/Documentation-AgentConnect/blob/26685161a2031f7145e02bede8c4d0aae97ba105/doc_fs/technique_fca/endpoints.md?plain=1#L324
logout: () => {
set(initialState);
},
@@ -10,6 +10,7 @@ import { LanguagePicker } from '../language/';
import { default as IconCells } from './assets/icon-cells.svg?url';
import { default as IconDesk } from './assets/icon-desk.svg?url';
import { default as IconFAQ } from './assets/icon-faq.svg?url';
import { default as IconGouv } from './assets/icon-gouv.svg?url';
import { default as IconMarianne } from './assets/icon-marianne.svg?url';
import IconMyAccount from './assets/icon-my-account.png';
@@ -41,23 +42,18 @@ export const Header = () => {
return (
<StyledHeader>
<RedStripe />
<Box
className="ml-bx mr-bx"
$align="center"
$justify="space-between"
$direction="row"
>
<Box>
<Box className="ml-bx mr-bx" $align="center" $justify="space-between">
<Box $direction="column">
<Image priority src={IconMarianne} alt={t('Marianne Logo')} />
<Box $align="center" $gap="6rem" $direction="row">
<Box $align="center" $gap="6rem">
<Image
priority
src={IconGouv}
alt={t('Freedom Equality Fraternity Logo')}
/>
<Box $align="center" $gap="1rem" $direction="row">
<Box $align="center" $gap="1rem">
<Image priority src={IconDesk} alt={t('Desk Logo')} />
<Text className="m-0" as="h2" $theme="primary">
<Text className="m-0" as="h2">
{t('Desk')}
</Text>
</Box>
@@ -72,22 +68,27 @@ export const Header = () => {
`}
$gap="5rem"
$justify="flex-end"
$direction="row"
>
<Box $align="center" $direction="row">
<Box $align="center">
<Button
aria-label={t('Access to FAQ')}
icon={<Image priority src={IconFAQ} alt={t('FAQ Icon')} />}
className="m-s c__button-no-bg p-0"
>
{t('FAQ')}
</Button>
<LanguagePicker />
</Box>
<Box $direction="row">
<Box>
<Box $direction="row" $align="center" $gap="1rem">
<Text $weight="bold" $theme="primary">
John Doe
</Text>
<Text $weight="bold">John Doe</Text>
<Image
width={58}
height={58}
priority
src={IconMyAccount}
alt={t(`Profile picture`)}
unoptimized
/>
</Box>
<Button
@@ -0,0 +1,14 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M21 3C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.455L2 22.5V4C2 3.44772 2.44772 3 3 3H21ZM20 5H4V18.385L5.763 17H20V5ZM13 14V16H11V14H13ZM12.4592 6.02822C14.2875 6.27003 15.6148 7.88878 15.4936 9.72908C15.3724 11.5694 13.8443 13.0001 12 13H11V11H12C12.7902 11 13.4448 10.387 13.4967 9.59851C13.5486 8.81006 12.9799 8.11655 12.1966 8.01295C11.4133 7.90935 10.6839 8.43117 10.529 9.206L8.567 8.813C8.92838 7.00447 10.6308 5.78641 12.4592 6.02822Z"
fill="#000091"
/>
</svg>

After

Width:  |  Height:  |  Size: 663 B

@@ -0,0 +1,5 @@
export * from './auth/';
export * from './header/';
export * from './language/';
export * from './menu/';
export * from './teams/';
@@ -49,7 +49,7 @@ export const LanguagePicker = () => {
$align="center"
>
<Image priority src={IconLanguage} alt={t('Language Icon')} />
<Text $theme="primary">{lang.toUpperCase()}</Text>
<Text>{lang.toUpperCase()}</Text>
</Box>
),
}));
@@ -1,13 +1,13 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box } from '@/components/';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import MenuItem from './MenuItems';
import IconRecent from './assets/icon-clock.svg';
import IconContacts from './assets/icon-contacts.svg';
import IconGroup from './assets/icon-group.svg';
import IconSearch from './assets/icon-search.svg';
import IconFavorite from './assets/icon-stars.svg';
@@ -21,7 +21,9 @@ export const Menu = () => {
className="m-0 p-0"
$background={colorsTokens()['primary-800']}
$height="100%"
$width="3.75rem"
$justify="space-between"
$direction="column"
>
<Box className="pt-b" $direction="column" $gap="0.8rem">
<MenuItem Icon={IconSearch} label={t('Search')} href="/" />
@@ -1,14 +1,24 @@
import { Button } from '@openfun/cunningham-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, StyledLink } from '@/components';
import { Box } from '@/components/';
import { useCunninghamTheme } from '@/cunningham';
import { SVGComponent } from '@/types/components';
import { Tooltip } from './Tooltip';
const StyledLink = styled(Link)`
text-decoration: none;
color: #ffffff33;
&[aria-current='page'] {
color: #ffffff;
}
`;
interface MenuItemProps {
Icon: SVGComponent;
label: string;
@@ -19,7 +29,7 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
const { t } = useTranslation();
const pathname = usePathname();
const { colorsTokens } = useCunninghamTheme();
const parentRef = useRef(null);
const buttonRef = useRef(null);
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const isActive = pathname === href;
@@ -41,10 +51,9 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
<StyledLink
href={href}
aria-current={isActive && 'page'}
ref={parentRef}
ref={buttonRef}
onMouseOver={() => setIsTooltipOpen(true)}
onMouseOut={() => setIsTooltipOpen(false)}
style={{ display: 'block' }}
>
<Box
className="m-st p-t"
@@ -77,7 +86,7 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
</Box>
{isTooltipOpen && (
<Tooltip
parentRef={parentRef}
buttonRef={buttonRef}
label={label}
backgroundColor={backgroundTooltip}
textColor={colorTooltip}
@@ -4,14 +4,14 @@ import React, { CSSProperties, useEffect, useState } from 'react';
import { Box, Text } from '@/components/';
interface TooltipProps {
parentRef: React.MutableRefObject<null>;
buttonRef: React.MutableRefObject<null>;
textColor: CSSProperties['color'];
backgroundColor: CSSProperties['color'];
label: string;
}
export const Tooltip = ({
parentRef,
buttonRef,
backgroundColor,
textColor,
label,
@@ -23,7 +23,7 @@ export const Tooltip = ({
}, []);
return (
<Popover parentRef={parentRef} onClickOutside={() => ''} borderless>
<Popover parentRef={buttonRef} onClickOutside={() => ''} borderless>
<Box
aria-label="tooltip"
className="ml-t p-t"

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 1020 B

@@ -1,130 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { AppWrapper } from '@/tests/utils';
import { TeamList } from '../components/Panel/TeamList';
window.HTMLElement.prototype.scroll = function () {};
jest.mock('next/router', () => ({
...jest.requireActual('next/router'),
useRouter: () => ({
query: {},
}),
}));
describe('PanelTeams', () => {
afterEach(() => {
fetchMock.restore();
});
it('renders with no team to display', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 0,
results: [],
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByText(
'Create your first team by clicking on the "Create a new team" button.',
),
).toBeInTheDocument();
});
it('renders an empty team', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [],
},
],
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByLabelText('Empty teams icon'),
).toBeInTheDocument();
});
it('renders a team with only 1 member', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'owner',
},
],
},
],
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByLabelText('Empty teams icon'),
).toBeInTheDocument();
});
it('renders a non-empty team', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'admin',
},
{
id: '2',
role: 'member',
},
],
},
],
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByLabelText('Teams icon')).toBeInTheDocument();
});
it('renders the error', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
status: 500,
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByText(
'Something bad happens, please refresh the page.',
),
).toBeInTheDocument();
});
});
@@ -0,0 +1,41 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { AppWrapper } from '@/tests/utils';
import { Teams } from '..';
describe('Teams', () => {
afterEach(() => {
fetchMock.restore();
});
it('checks Teams rendering', async () => {
fetchMock.mock(`/api/teams/`, {
results: [
{
id: '1',
name: 'Team 1',
},
{
id: '2',
name: 'Team 2',
},
],
});
render(<Teams />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByRole('button', {
name: 'Create Team',
}),
).toBeInTheDocument();
expect(screen.getByText(/Team 1/)).toBeInTheDocument();
expect(screen.getByText(/Team 2/)).toBeInTheDocument();
});
});
@@ -1,5 +0,0 @@
export * from './types';
export * from './useCreateTeam';
export * from './useTeam';
export * from './useTeams';
export * from './useTeamsAccesses';
@@ -1,25 +0,0 @@
export enum Role {
MEMBER = 'member',
ADMIN = 'administrator',
OWNER = 'owner',
}
export interface Access {
id: string;
role: Role;
user: User;
}
export interface Team {
id: string;
name: string;
accesses: Access[];
created_at: string;
updated_at: string;
}
export interface User {
id: string;
email: string;
name?: string;
}
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { fetchAPI } from '@/api';
import { KEY_LIST_TEAM } from './useTeams';
@@ -8,8 +8,11 @@ type CreateTeamResponse = {
id: string;
name: string;
};
export interface CreateTeamResponseError {
detail: string;
}
export const createTeam = async (name: string): Promise<CreateTeamResponse> => {
export const createTeam = async (name: string) => {
const response = await fetchAPI(`teams/`, {
method: 'POST',
body: JSON.stringify({
@@ -18,28 +21,21 @@ export const createTeam = async (name: string): Promise<CreateTeamResponse> => {
});
if (!response.ok) {
throw new APIError(
'Failed to create the team',
await errorCauses(response),
);
throw new Error(`Couldn't create team: ${response.statusText}`);
}
return response.json() as Promise<CreateTeamResponse>;
return response.json();
};
interface CreateTeamProps {
onSuccess: (data: CreateTeamResponse) => void;
}
export function useCreateTeam({ onSuccess }: CreateTeamProps) {
export function useCreateTeam() {
const queryClient = useQueryClient();
return useMutation<CreateTeamResponse, APIError, string>({
return useMutation<CreateTeamResponse, CreateTeamResponseError, string>({
mutationFn: createTeam,
onSuccess: (data) => {
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEAM],
exact: true,
});
onSuccess(data);
},
});
}
@@ -1,32 +0,0 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Team } from './types';
export type TeamParams = {
id: string;
};
export const getTeam = async ({ id }: TeamParams): Promise<Team> => {
const response = await fetchAPI(`teams/${id}`);
if (!response.ok) {
throw new APIError('Failed to get the team', await errorCauses(response));
}
return response.json() as Promise<Team>;
};
export const KEY_TEAM = 'team';
export function useTeam(
param: TeamParams,
queryConfig?: UseQueryOptions<Team, APIError, Team>,
) {
return useQuery<Team, APIError, Team>({
queryKey: [KEY_TEAM, param],
queryFn: () => getTeam(param),
...queryConfig,
});
}
@@ -1,71 +1,38 @@
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query';
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { APIList, fetchAPI } from '@/api';
import { Team } from './types';
export enum TeamsOrdering {
BY_CREATED_ON = 'created_at',
BY_CREATED_ON_DESC = '-created_at',
interface TeamResponse {
id: string;
name: string;
}
export type TeamsParams = {
ordering: TeamsOrdering;
};
type TeamsAPIParams = TeamsParams & {
page: number;
};
type TeamsResponse = APIList<TeamResponse>;
export interface TeamsResponseError {
detail: string;
}
type TeamsResponse = APIList<Team>;
export const getTeams = async ({
ordering,
page,
}: TeamsAPIParams): Promise<TeamsResponse> => {
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
const response = await fetchAPI(`teams/?page=${page}${orderingQuery}`);
export const getTeams = async () => {
const response = await fetchAPI(`teams/`);
if (!response.ok) {
throw new APIError('Failed to get the teams', await errorCauses(response));
throw new Error(`Couldn't fetch teams: ${response.statusText}`);
}
return response.json() as Promise<TeamsResponse>;
return response.json();
};
export const KEY_LIST_TEAM = 'teams';
export function useTeams(
param: TeamsParams,
queryConfig?: DefinedInitialDataInfiniteOptions<
queryConfig?: UseQueryOptions<
TeamsResponse,
APIError,
InfiniteData<TeamsResponse>,
QueryKey,
number
TeamsResponseError,
TeamsResponse
>,
) {
return useInfiniteQuery<
TeamsResponse,
APIError,
InfiniteData<TeamsResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_TEAM, param],
queryFn: ({ pageParam }) =>
getTeams({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
return useQuery<TeamsResponse, TeamsResponseError, TeamsResponse>({
queryKey: [KEY_LIST_TEAM],
queryFn: getTeams,
...queryConfig,
});
}
@@ -1,41 +0,0 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Access } from './types';
export type TeamAccessesAPIParams = {
page: number;
teamId: string;
};
type AccessesResponse = APIList<Access>;
export const getTeamAccesses = async ({
page,
teamId,
}: TeamAccessesAPIParams): Promise<AccessesResponse> => {
const response = await fetchAPI(`teams/${teamId}/accesses/?page=${page}`);
if (!response.ok) {
throw new APIError(
'Failed to get the team accesses',
await errorCauses(response),
);
}
return response.json() as Promise<AccessesResponse>;
};
export const KEY_LIST_TEAM_ACCESSES = 'teams-accesses';
export function useTeamAccesses(
params: TeamAccessesAPIParams,
queryConfig?: UseQueryOptions<AccessesResponse, APIError, AccessesResponse>,
) {
return useQuery<AccessesResponse, APIError, AccessesResponse>({
queryKey: [KEY_LIST_TEAM_ACCESSES, params],
queryFn: () => getTeamAccesses(params),
...queryConfig,
});
}
@@ -1,13 +0,0 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_178_17838)">
<path
d="M16.25 8.75H13.75V13.75H8.75V16.25H13.75V21.25H16.25V16.25H21.25V13.75H16.25V8.75ZM15 2.5C8.1 2.5 2.5 8.1 2.5 15C2.5 21.9 8.1 27.5 15 27.5C21.9 27.5 27.5 21.9 27.5 15C27.5 8.1 21.9 2.5 15 2.5ZM15 25C9.4875 25 5 20.5125 5 15C5 9.4875 9.4875 5 15 5C20.5125 5 25 9.4875 25 15C25 20.5125 20.5125 25 15 25Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_178_17838">
<rect width="30" height="30" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 604 B

@@ -1,13 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_508_5524)">
<path
d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM4 12C4 7.58 7.58 4 12 4C13.85 4 15.55 4.63 16.9 5.69L5.69 16.9C4.63 15.55 4 13.85 4 12ZM12 20C10.15 20 8.45 19.37 7.1 18.31L18.31 7.1C19.37 8.45 20 10.15 20 12C20 16.42 16.42 20 12 20Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_508_5524">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 578 B

@@ -1,13 +0,0 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_178_17837)">
<path
d="M11.25 3.75L6.25 8.7375H10V17.5H12.5V8.7375H16.25L11.25 3.75ZM20 21.2625V12.5H17.5V21.2625H13.75L18.75 26.25L23.75 21.2625H20Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_178_17837">
<rect width="30" height="30" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 429 B

@@ -1,89 +0,0 @@
import { DataGrid, usePagination } from '@openfun/cunningham-react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import IconUser from '@/assets/icons/icon-user.svg';
import { Box, Card, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Team, useTeamAccesses } from '@/features/teams/api/';
import { PAGE_SIZE } from '@/features/teams/conf';
interface MemberGridProps {
team: Team;
}
export const MemberGrid = ({ team }: MemberGridProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const pagination = usePagination({
pageSize: PAGE_SIZE,
});
const { page, pageSize, setPagesCount } = pagination;
const { data, isLoading, error } = useTeamAccesses({
teamId: team.id,
page,
});
const accesses = data?.results;
useEffect(() => {
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
}, [data?.count, pageSize, setPagesCount]);
return (
<Card
className="m-b pb-s"
$overflow="auto"
$css={`
margin-top:0;
& .c__pagination__goto {
display: none;
}
& table th:first-child,
& table td:first-child {
padding-right: 0;
width: 0;
}
`}
aria-label={t('List members card')}
>
{error && <TextErrors causes={error.cause} />}
<DataGrid
columns={[
{
id: 'icon-user',
renderCell() {
return (
<Box $direction="row" $align="center">
<IconUser
aria-label={t('Member icon')}
width={20}
height={20}
color={colorsTokens()['primary-600']}
/>
</Box>
);
},
},
{
headerName: t('Names'),
field: 'user.name',
},
{
field: 'user.email',
headerName: t('Emails'),
},
{
field: 'role',
headerName: t('Roles'),
},
]}
rows={accesses || []}
isLoading={isLoading}
pagination={pagination}
/>
</Card>
);
};
@@ -1,42 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { PanelActions } from './PanelActions';
import { TeamList } from './TeamList';
export const Panel = () => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$width="100%"
$maxWidth="20rem"
$minWidth="14rem"
$css={`
border-right: 1px solid ${colorsTokens()['primary-300']};
`}
$height="inherit"
aria-label="Teams panel"
>
<Box
className="p-s"
$direction="row"
$align="center"
$justify="space-between"
$css={`
border-bottom: 1px solid ${colorsTokens()['primary-300']};
`}
>
<Text $weight="bold" $size="1.25rem">
{t('Recents')}
</Text>
<PanelActions />
</Box>
<TeamList />
</Box>
);
};
@@ -1,74 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import React, { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, StyledLink } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { TeamsOrdering } from '@/features/teams/api/';
import IconAdd from '@/features/teams/assets/icon-add.svg';
import IconSort from '@/features/teams/assets/icon-sort.svg';
import { useTeamStore } from '@/features/teams/store/useTeamsStore';
const ButtonSort = styled(Button)<{
$background: CSSProperties['background'];
$color: CSSProperties['color'];
}>`
&.c__button {
svg {
background-color: transparent;
transition: all 0.3s;
}
&.c__button--active svg {
background-color: ${({ $background }) => $background};
border-radius: 10rem;
color: ${({ $color }) => $color};
}
}
`;
export const PanelActions = () => {
const { t } = useTranslation();
const { changeOrdering, ordering } = useTeamStore();
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$direction="row"
$gap="1rem"
$css={`
& button {
padding: 0;
svg {
padding: 0.1rem;
}
}
`}
>
<ButtonSort
aria-label={t('Sort the teams')}
icon={
<IconSort width={30} height={30} aria-label={t('Sort teams icon')} />
}
color="tertiary"
className="c__button-no-bg p-0 m-0"
onClick={changeOrdering}
active={ordering === TeamsOrdering.BY_CREATED_ON}
$background={colorsTokens()['primary-200']}
$color={colorsTokens()['primary-600']}
/>
<StyledLink href="/teams/create">
<Button
aria-label={t('Add a team')}
icon={
<IconAdd width={30} height={30} aria-label={t('Add team icon')} />
}
color="tertiary"
className="c__button-no-bg p-0 m-0"
/>
</StyledLink>
</Box>
);
};
@@ -1,98 +0,0 @@
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Team } from '@/features/teams/api/';
import IconNone from '@/features/teams/assets/icon-none.svg';
interface TeamProps {
team: Team;
}
export const TeamItem = ({ team }: TeamProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const {
query: { id },
} = useRouter();
// There is at least 1 owner in the team
const hasMembers = team.accesses.length > 1;
const isActive = team.id === id;
const commonProps = {
className: 'p-t',
width: 52,
style: {
borderRadius: '10px',
flexShrink: 0,
background: '#fff',
},
};
const activeStyle = `
border-right: 4px solid ${colorsTokens()['primary-600']};
background: ${colorsTokens()['primary-400']};
span{
color: ${colorsTokens()['primary-text']};
}
`;
const hoverStyle = `
&:hover{
border-right: 4px solid ${colorsTokens()['primary-400']};
background: ${colorsTokens()['primary-300']};
span{
color: ${colorsTokens()['primary-text']};
}
}
`;
return (
<Box
className="m-0"
as="li"
$css={`
transition: all 0.2s ease-in;
border-right: 4px solid transparent;
${isActive ? activeStyle : hoverStyle}
`}
>
<StyledLink className="p-s pt-t pb-t" href={`/teams/${team.id}`}>
<Box $align="center" $direction="row" $gap="0.5rem">
{hasMembers ? (
<IconGroup
aria-label={t(`Teams icon`)}
color={colorsTokens()['primary-500']}
{...commonProps}
style={{
...commonProps.style,
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
) : (
<IconNone
aria-label={t(`Empty teams icon`)}
color={colorsTokens()['greyscale-500']}
{...commonProps}
style={{
...commonProps.style,
border: `1px solid ${colorsTokens()['greyscale-300']}`,
}}
/>
)}
<Text
$weight="bold"
$color={!hasMembers ? colorsTokens()['greyscale-600'] : undefined}
>
{team.name}
</Text>
</Box>
</StyledLink>
</Box>
);
};
@@ -1,93 +0,0 @@
import { Loader } from '@openfun/cunningham-react';
import React, { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { InfiniteScroll } from '@/components/InfiniteScroll';
import { Team, useTeams } from '@/features/teams/api/';
import { useTeamStore } from '@/features/teams/store/useTeamsStore';
import { TeamItem } from './TeamItem';
interface PanelTeamsStateProps {
isLoading: boolean;
isError: boolean;
teams?: Team[];
}
const TeamListState = ({ isLoading, isError, teams }: PanelTeamsStateProps) => {
const { t } = useTranslation();
if (isError) {
return (
<Box $justify="center" className="mb-b">
<Text $theme="danger" $align="center" $textAlign="center">
{t('Something bad happens, please refresh the page.')}
</Text>
</Box>
);
}
if (isLoading) {
return (
<Box $align="center" className="m-l">
<Loader />
</Box>
);
}
if (!teams?.length) {
return (
<Box $justify="center" className="m-s">
<Text as="p" className="mb-0 mt-0" $theme="greyscale" $variation="500">
{t('0 group to display.')}
</Text>
<Text as="p" $theme="greyscale" $variation="500">
{t(
'Create your first team by clicking on the "Create a new team" button.',
)}
</Text>
</Box>
);
}
return teams.map((team) => <TeamItem team={team} key={team.id} />);
};
export const TeamList = () => {
const ordering = useTeamStore((state) => state.ordering);
const {
data,
isError,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTeams({
ordering,
});
const containerRef = useRef<HTMLDivElement>(null);
const teams = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as Team[]);
}, [data?.pages]);
return (
<Box $css="overflow: auto;" ref={containerRef}>
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}
next={() => {
void fetchNextPage();
}}
scrollContainer={containerRef.current}
as="ul"
className="p-0 mt-0"
role="listbox"
>
<TeamListState isLoading={isLoading} isError={isError} teams={teams} />
</InfiniteScroll>
</Box>
);
};
@@ -1,80 +0,0 @@
import { DateTime, DateTimeFormatOptions } from 'luxon';
import React from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group2.svg';
import { Box, Card, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Team } from '../api/types';
const format: DateTimeFormatOptions = {
month: '2-digit',
day: '2-digit',
year: 'numeric',
};
interface TeamInfoProps {
team: Team;
}
export const TeamInfo = ({ team }: TeamInfoProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { i18n } = useTranslation();
const created_at = DateTime.fromISO(team.created_at)
.setLocale(i18n.language)
.toLocaleString(format);
const updated_at = DateTime.fromISO(team.updated_at)
.setLocale(i18n.language)
.toLocaleString(format);
return (
<Card className="m-b" style={{ paddingBottom: 0 }}>
<Box className="m-b" $direction="row" $align="center" $gap="1.5rem">
<IconGroup
width={44}
color={colorsTokens()['primary-text']}
aria-label={t('icon group')}
style={{
flexShrink: 0,
alignSelf: 'start',
}}
/>
<Box>
<Text as="h3" $weight="bold" $size="1.25rem" className="mt-0">
{t('Members of “{{teamName}}“', {
teamName: team.name,
})}
</Text>
<Text $size="m">
{t('Add people to the “{{teamName}}“ group.', {
teamName: team.name,
})}
</Text>
</Box>
</Box>
<Box
className="p-s"
$gap="1rem"
$direction="row"
$justify="space-evenly"
$css={`border-top: 1px solid ${colorsTokens()['card-border']};`}
>
<Text $size="s">
{t('{{count}} member', { count: team.accesses.length })}
</Text>
<Text $size="s" $direction="row">
{t('Created at')}&nbsp;
<Text $weight="bold">{created_at}</Text>
</Text>
<Text $size="s" $direction="row">
{t('Last update at')}&nbsp;
<Text $weight="bold">{updated_at}</Text>
</Text>
</Box>
</Card>
);
};
@@ -1,3 +0,0 @@
export * from './Panel/Panel';
export * from './TeamInfo';
export * from './Member/MemberGrid';
@@ -1 +0,0 @@
export const PAGE_SIZE = 20;

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