Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ac11f27fb | |||
| d983397a34 | |||
| b010a7b5a7 | |||
| e16f51ca20 | |||
| 9d30bc88f1 | |||
| 1da978e121 | |||
| 3bf8965209 | |||
| 5b9d2cccc5 | |||
| 81243cfc9a | |||
| 70b1b996df | |||
| 29d274ab7c | |||
| f17771fc9b | |||
| 65e78cde68 | |||
| 33288ab225 | |||
| a3c0069697 | |||
| b307b373bb | |||
| f21740e5e5 | |||
| 035a7a1fcc | |||
| 3f7e5c88bc | |||
| 51064ec236 | |||
| 195e738c3c | |||
| 54497c1261 | |||
| 8d7c545d1a | |||
| 8cbfb38cc4 | |||
| 4bd8095975 | |||
| fc8dc24ba2 | |||
| 95219a33b3 | |||
| 26fbe9fbe7 | |||
| 4cacfd3a45 | |||
| 38c4d33791 | |||
| ec28c28d47 | |||
| 927d0e5a22 | |||
| 699854e76b | |||
| 63e059a4e6 | |||
| 5113eb013b | |||
| 45d05873e2 | |||
| 1f3ab759d7 | |||
| 8b5f5bf092 | |||
| cd2efbe40d | |||
| 8b0c20dbdc | |||
| d0562029e8 | |||
| 77efb1a89c | |||
| 4566e132e1 | |||
| f818715a45 | |||
| 7d90092020 | |||
| 469903c9eb | |||
| 1f1253ab21 | |||
| 6620932371 | |||
| 8e537d962c | |||
| 6080af961a | |||
| aba376702f | |||
| 1e38174c1b | |||
| 0ab9f16cb3 | |||
| c71463a385 | |||
| 47ffa60a94 | |||
| fafffd2391 | |||
| 36e2dc2378 | |||
| 4f0465fd32 | |||
| 148ea81aa9 |
@@ -35,20 +35,10 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push backend
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
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 }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -115,6 +115,7 @@ 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:
|
||||
@@ -124,7 +125,7 @@ jobs:
|
||||
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
make run
|
||||
|
||||
- name: Apply DRH migrations
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
|
||||
@@ -216,7 +217,7 @@ jobs:
|
||||
DJANGO_CONFIGURATION: Test
|
||||
DJANGO_SETTINGS_MODULE: people.settings
|
||||
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
|
||||
DJANGO_JWT_PRIVATE_SIGNING_KEY: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
|
||||
DB_HOST: localhost
|
||||
DB_NAME: people
|
||||
DB_USER: dinum
|
||||
|
||||
@@ -11,40 +11,6 @@ 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
|
||||
|
||||
@@ -58,7 +24,7 @@ RUN mkdir /install && \
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 as mail-builder
|
||||
FROM node:18 as mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
|
||||
@@ -92,10 +92,8 @@ bootstrap: \
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the dev and demo containers
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev
|
||||
@$(COMPOSE) build nginx
|
||||
@$(COMPOSE) build app
|
||||
.PHONY: build
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
|
||||
@@ -67,12 +67,9 @@ 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:
|
||||
@@ -92,15 +89,15 @@ services:
|
||||
- app
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: .
|
||||
target: frontend
|
||||
image: people:frontend
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8088:8088"
|
||||
- "8082:8082"
|
||||
- "8083:8083"
|
||||
- "8088:8088"
|
||||
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
|
||||
@@ -171,7 +168,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:
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
"users": [
|
||||
{
|
||||
"username": "people",
|
||||
"email": "people@people.world",
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
@@ -56,12 +59,43 @@
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e",
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e"
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
@@ -1076,7 +1110,7 @@
|
||||
},
|
||||
{
|
||||
"id": "79712bcf-b7f7-4ca3-b97c-418f48fded9b",
|
||||
"name": "given name",
|
||||
"name": "first name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
@@ -1085,7 +1119,7 @@
|
||||
"user.attribute": "firstName",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "given_name",
|
||||
"claim.name": "first_name",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
@@ -1106,7 +1140,7 @@
|
||||
},
|
||||
{
|
||||
"id": "7f741e96-41fe-4021-bbfd-506e7eb94e69",
|
||||
"name": "family name",
|
||||
"name": "last name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
@@ -1115,7 +1149,7 @@
|
||||
"user.attribute": "lastName",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "family_name",
|
||||
"claim.name": "last_name",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
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 /usr/share/nginx/html;
|
||||
|
||||
root /home/desk;
|
||||
|
||||
location / {
|
||||
try_files $uri index.html $uri/ =404;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# For the CI job test-e2e
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
@@ -13,6 +13,20 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -82,6 +82,15 @@ 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",
|
||||
@@ -110,3 +119,31 @@ 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()
|
||||
|
||||
@@ -26,28 +26,66 @@ class ContactSerializer(serializers.ModelSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
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):
|
||||
"""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", "email", "data", "is_device", "is_staff"]
|
||||
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
|
||||
|
||||
def get_data(self, user) -> dict:
|
||||
"""Return contact data for the user."""
|
||||
return user.profile_contact.data if user.profile_contact else {}
|
||||
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")
|
||||
|
||||
|
||||
class TeamAccessSerializer(serializers.ModelSerializer):
|
||||
@@ -126,17 +164,52 @@ 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"]
|
||||
read_only_fields = ["id", "accesses", "abilities", "slug"]
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_abilities(self, team) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
@@ -148,3 +221,12 @@ 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"]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
"""
|
||||
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),
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"""API endpoints"""
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
|
||||
from django.db.models import Func, Max, OuterRef, Prefetch, Q, Subquery, Value
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
filters,
|
||||
mixins,
|
||||
pagination,
|
||||
response,
|
||||
@@ -185,7 +186,7 @@ class UserViewSet(
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all().select_related("profile_contact")
|
||||
queryset = models.User.objects.all()
|
||||
serializer_class = serializers.UserSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
pagination_class = Pagination
|
||||
@@ -198,6 +199,12 @@ 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
|
||||
@@ -226,9 +233,12 @@ class UserViewSet(
|
||||
"""
|
||||
Return information on currently logged user
|
||||
"""
|
||||
context = {"request": request}
|
||||
user = request.user
|
||||
user.main_identity = models.Identity.objects.filter(
|
||||
user=user, is_main=True
|
||||
).first()
|
||||
return response.Response(
|
||||
self.serializer_class(request.user, context=context).data
|
||||
self.serializer_class(user, context={"request": request}).data
|
||||
)
|
||||
|
||||
|
||||
@@ -244,6 +254,9 @@ 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):
|
||||
@@ -301,7 +314,8 @@ class TeamAccessViewSet(
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = models.TeamAccess.objects.all().select_related("user")
|
||||
serializer_class = serializers.TeamAccessSerializer
|
||||
list_serializer_class = serializers.TeamAccessReadOnlySerializer
|
||||
detail_serializer_class = serializers.TeamAccessSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list team accesses"""
|
||||
@@ -318,22 +332,35 @@ 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 == "list":
|
||||
if self.action in {"list", "retrieve"}:
|
||||
# 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()
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Authentication for the People core app."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -9,18 +8,15 @@ 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, as well as the userinfo endpoint behavior in Agent Connect.
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
@@ -34,9 +30,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: 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.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
@@ -70,24 +66,39 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
email = user_info.get("email")
|
||||
sub = user_info.get("sub")
|
||||
# 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
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if sub is None:
|
||||
raise InvalidToken(
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
user = (
|
||||
self.UserModel.objects.filter(identities__sub=sub)
|
||||
.annotate(identity_email=models.F("identities__email"))
|
||||
.annotate(
|
||||
identity_email=models.F("identities__email"),
|
||||
identity_name=models.F("identities__name"),
|
||||
)
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
|
||||
if user:
|
||||
if email and email != user.identity_email:
|
||||
Identity.objects.filter(sub=sub).update(email=email)
|
||||
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)
|
||||
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(user_info)
|
||||
@@ -96,16 +107,15 @@ 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 InvalidToken(
|
||||
raise SuspiciousOperation(
|
||||
_("Claims contained no recognizable user identification")
|
||||
)
|
||||
|
||||
user = self.UserModel.objects.create(password="!", email=email) # noqa: S106
|
||||
Identity.objects.create(user=user, sub=sub, email=email)
|
||||
user = self.UserModel.objects.create(password="!") # noqa: S106
|
||||
Identity.objects.create(
|
||||
user=user, sub=sub, email=claims.get("email"), name=claims.get("name")
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
@@ -139,6 +139,7 @@ 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):
|
||||
@@ -170,3 +171,15 @@ 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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Generated by Django 5.0.1 on 2024-02-06 15:08
|
||||
# Generated by Django 5.0.2 on 2024-02-23 06:46
|
||||
|
||||
import core.models
|
||||
import django.contrib.auth.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(max_length=100, unique=True)),
|
||||
('slug', models.SlugField(editable=False, max_length=100, unique=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Team',
|
||||
@@ -60,7 +60,7 @@ class Migration(migrations.Migration):
|
||||
'db_table': 'people_user',
|
||||
},
|
||||
managers=[
|
||||
('objects', core.models.UserManager()),
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -95,6 +95,7 @@ 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)),
|
||||
],
|
||||
@@ -105,6 +106,21 @@ 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=[
|
||||
|
||||
@@ -4,12 +4,14 @@ 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 _
|
||||
@@ -145,16 +147,6 @@ 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."""
|
||||
|
||||
@@ -198,7 +190,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
),
|
||||
)
|
||||
|
||||
objects = UserManager()
|
||||
objects = auth_models.UserManager()
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = []
|
||||
@@ -262,6 +254,7 @@ 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,
|
||||
@@ -286,7 +279,8 @@ class Identity(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
main_str = "[main]" if self.is_main else ""
|
||||
return f"{self.email:s}{main_str:s}"
|
||||
id_str = self.email or self.sub
|
||||
return f"{id_str:s}{main_str:s}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Ensure users always have one and only one main identity."""
|
||||
@@ -448,3 +442,31 @@ 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)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
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),
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
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),
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
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,7 +26,10 @@ 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
|
||||
|
||||
@@ -119,3 +122,61 @@ 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,28 +58,19 @@ 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
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
|
||||
assert sorted(response.json().pop("accesses")) == sorted(
|
||||
[
|
||||
{
|
||||
"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"],
|
||||
str(access1.id),
|
||||
str(access2.id),
|
||||
]
|
||||
)
|
||||
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,8 +121,10 @@ 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"]: # pylint: disable=R1733
|
||||
if key in ["id", "accesses", "created_at"]:
|
||||
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]
|
||||
@@ -153,8 +155,10 @@ 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"]:
|
||||
if key in ["id", "accesses", "created_at"]:
|
||||
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]
|
||||
|
||||
@@ -1,846 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -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")
|
||||
nicole = factories.IdentityFactory(email="nicole_foole@work.com")
|
||||
frank = factories.IdentityFactory(email="frank_poole@work.com")
|
||||
factories.IdentityFactory(email="heywood_floyd@work.com")
|
||||
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)
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
@@ -91,8 +91,26 @@ def test_api_users_authenticated_list_by_email():
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
|
||||
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),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_multiple_identities_single_user():
|
||||
@@ -132,12 +150,16 @@ def test_api_users_authenticated_list_multiple_identities_multiple_users():
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory()
|
||||
davina = factories.UserFactory()
|
||||
prudence = factories.UserFactory()
|
||||
factories.IdentityFactory(user=dave, email="david.bowman@work.com")
|
||||
dave_identity = factories.IdentityFactory(
|
||||
user=dave, email="david.bowman@work.com", is_main=True
|
||||
)
|
||||
factories.IdentityFactory(user=dave, email="babibou@ehehe.com")
|
||||
factories.IdentityFactory(user=davina, email="davina.bowan@work.com")
|
||||
factories.IdentityFactory(user=prudence, email="prudence.crandall@work.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"
|
||||
)
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
@@ -146,8 +168,35 @@ def test_api_users_authenticated_list_multiple_identities_multiple_users():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 3
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id), str(davina.id), str(prudence.id)]
|
||||
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),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_uppercase_content():
|
||||
@@ -291,7 +340,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()
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
@@ -310,12 +359,12 @@ def test_api_users_retrieve_me_authenticated():
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"email": str(user.email),
|
||||
"name": str(identity.name),
|
||||
"email": str(identity.email),
|
||||
"language": user.language,
|
||||
"timezone": str(user.timezone),
|
||||
"is_device": False,
|
||||
"is_staff": False,
|
||||
"data": user.profile_contact.data,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Unit tests for the `get_or_create_user` function."""
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
|
||||
from core import factories, models
|
||||
from core import models
|
||||
from core.authentication import OIDCAuthenticationBackend
|
||||
from core.factories import IdentityFactory
|
||||
|
||||
@@ -22,7 +21,7 @@ def test_authentication_getter_existing_user_no_email(
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
# Create a user and its identity
|
||||
identity = IdentityFactory()
|
||||
identity = IdentityFactory(name=None)
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
@@ -35,7 +34,7 @@ def test_authentication_getter_existing_user_no_email(
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
access_token=AccessToken(), id_token=None, payload=None
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity.refresh_from_db()
|
||||
@@ -47,45 +46,82 @@ 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()
|
||||
identity = IdentityFactory(name="John Doe")
|
||||
|
||||
# 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}
|
||||
return {
|
||||
"sub": identity.sub,
|
||||
"email": identity.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# Only 1 query if the email has not changed
|
||||
# Only 1 query because email and names have not changed
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
access_token=AccessToken(), id_token=None, payload=None
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
new_email = "test@fooo.com"
|
||||
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
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": identity.sub, "email": new_email}
|
||||
return {
|
||||
"sub": identity.sub,
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# Additional update query if the email has changed
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_or_create_user(
|
||||
access_token=AccessToken(), id_token=None, payload=None
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity.refresh_from_db()
|
||||
assert identity.email == new_email
|
||||
assert identity.email == email
|
||||
assert identity.name == f"{first_name:s} {last_name:s}"
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
assert user == identity.user
|
||||
@@ -105,7 +141,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=AccessToken(), id_token=None, payload=None
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity = user.identities.get()
|
||||
@@ -120,26 +156,28 @@ 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 info contains an email, created user's email should be set.
|
||||
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.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "people@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token=AccessToken(), id_token=None, payload=None
|
||||
access_token="test-token", 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 == email
|
||||
assert user.email is None
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -155,10 +193,9 @@ 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(
|
||||
InvalidToken, match="User info contained no recognizable user identification"
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
):
|
||||
user = klass.get_or_create_user(
|
||||
access_token=AccessToken(), id_token=None, payload=None
|
||||
)
|
||||
klass.get_or_create_user(access_token="test-token", 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 subfield should be 255 characters maximum."""
|
||||
"""The 'sub' field 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 subfield should accept periods, dashes, +, @ and underscores."""
|
||||
"""The 'sub' field 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 subfield should not accept spaces."""
|
||||
"""The 'sub' field 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 subfield should accept upper case characters."""
|
||||
"""The 'sub' field should accept upper case characters."""
|
||||
identity = factories.IdentityFactory(sub="John")
|
||||
assert identity.sub == "John"
|
||||
|
||||
|
||||
def test_models_identities_sub_ascii():
|
||||
"""The subfield should accept non ASCII letters."""
|
||||
"""The 'sub' field should accept non ASCII letters."""
|
||||
identity = factories.IdentityFactory(sub="rené")
|
||||
assert identity.sub == "rené"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
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")
|
||||
@@ -1,25 +0,0 @@
|
||||
"""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
|
||||
@@ -1,10 +0,0 @@
|
||||
"""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,10 +11,14 @@ 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")
|
||||
|
||||
|
||||
@@ -134,6 +138,7 @@ def create_demo(stdout):
|
||||
sub=uuid4(),
|
||||
email=f"identity{i:d}{user_email:s}",
|
||||
is_main=(i == 0),
|
||||
name=fake.name(),
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
@@ -228,7 +228,18 @@ class Base(Configuration):
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_THROTTLE_RATES": {"sustained": "150/hour", "burst": "20/minute"},
|
||||
"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,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
@@ -257,11 +268,12 @@ 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([], environ_name="CORS_ALLOWED_ORIGINS")
|
||||
CORS_ALLOWED_ORIGINS = values.ListValue([])
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
|
||||
|
||||
# Sentry
|
||||
@@ -292,7 +304,8 @@ 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
|
||||
@@ -331,6 +344,12 @@ 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):
|
||||
@@ -411,7 +430,7 @@ class Development(Base):
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072"]
|
||||
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"]
|
||||
DEBUG = True
|
||||
|
||||
SESSION_COOKIE_NAME = "people_sessionid"
|
||||
@@ -558,6 +577,8 @@ 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",
|
||||
|
||||
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.34.39",
|
||||
"boto3==1.34.51",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.3.6",
|
||||
"django-configurations==2.5",
|
||||
@@ -35,10 +35,9 @@ 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.1.0",
|
||||
"dockerflow==2024.2.0",
|
||||
"easy_thumbnails==2.8.5",
|
||||
"factory_boy==3.3.0",
|
||||
"gunicorn==21.2.0",
|
||||
@@ -47,7 +46,7 @@ dependencies = [
|
||||
"psycopg[binary]==3.1.18",
|
||||
"PyJWT==2.8.0",
|
||||
"requests==2.31.0",
|
||||
"sentry-sdk==1.40.3",
|
||||
"sentry-sdk==1.40.6",
|
||||
"url-normalize==1.4.3",
|
||||
"whitenoise==6.6.0",
|
||||
"mozilla-django-oidc==4.0.0",
|
||||
@@ -64,18 +63,18 @@ dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.2.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.21.0",
|
||||
"ipython==8.22.1",
|
||||
"pyfakefs==5.3.5",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.0.3",
|
||||
"pylint==3.1.0",
|
||||
"pytest-cov==4.1.0",
|
||||
"pytest-django==4.8.0",
|
||||
"pytest==8.0.0",
|
||||
"pytest==8.0.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.5.0",
|
||||
"responses==0.24.1",
|
||||
"ruff==0.2.1",
|
||||
"types-requests==2.31.0.20240125",
|
||||
"responses==0.25.0",
|
||||
"ruff==0.2.2",
|
||||
"types-requests==2.31.0.20240218",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
NEXT_PUBLIC_API_URL=http://kubernetes-service-name-wip:8071/api/v1.0/
|
||||
@@ -3,6 +3,8 @@ const config = {
|
||||
default: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'primary-100': '#EDF5FA',
|
||||
'primary-150': '#E5EEFA',
|
||||
'info-150': '#E5EEFA',
|
||||
@@ -76,6 +78,7 @@ 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)',
|
||||
},
|
||||
@@ -83,6 +86,8 @@ 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)',
|
||||
@@ -175,6 +180,7 @@ const config = {
|
||||
dsfr: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-text': '#000091',
|
||||
'primary-100': '#f5f5fe',
|
||||
'primary-200': '#ececfe',
|
||||
@@ -195,10 +201,11 @@ const config = {
|
||||
'secondary-700': '#3b2424',
|
||||
'secondary-800': '#341f1f',
|
||||
'secondary-900': '#2b1919',
|
||||
'greyscale-000': '#cecece',
|
||||
'greyscale-100': '#f6f6f6',
|
||||
'greyscale-200': '#eeeeee',
|
||||
'greyscale-300': '#e5e5e5',
|
||||
'greyscale-text': '#303C4B',
|
||||
'greyscale-000': '#f6f6f6',
|
||||
'greyscale-100': '#eeeeee',
|
||||
'greyscale-200': '#e5e5e5',
|
||||
'greyscale-300': '#e1e1e1',
|
||||
'greyscale-400': '#dddddd',
|
||||
'greyscale-500': '#cecece',
|
||||
'greyscale-600': '#7b7b7b',
|
||||
@@ -258,28 +265,75 @@ const config = {
|
||||
'border-radius': '0',
|
||||
},
|
||||
button: {
|
||||
'border-radius': '2px',
|
||||
'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)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'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': {
|
||||
'forms-fileuploader': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-fileuploader': {
|
||||
'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-textarea': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/** @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) =>
|
||||
|
||||
@@ -16,25 +16,27 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@openfun/cunningham-react": "2.4.0",
|
||||
"@tanstack/react-query": "5.18.1",
|
||||
"i18next": "23.7.16",
|
||||
"next": "14.1.0",
|
||||
"@tanstack/react-query": "5.24.8",
|
||||
"i18next": "23.10.0",
|
||||
"luxon": "3.4.4",
|
||||
"next": "14.1.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-i18next": "14.0.0",
|
||||
"react-i18next": "14.0.5",
|
||||
"styled-components": "6.1.8",
|
||||
"zustand": "4.5.0"
|
||||
"zustand": "4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.18.1",
|
||||
"@testing-library/jest-dom": "6.4.1",
|
||||
"@tanstack/react-query-devtools": "5.24.8",
|
||||
"@testing-library/jest-dom": "6.4.2",
|
||||
"@testing-library/react": "14.2.1",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.2.53",
|
||||
"@types/react": "18.2.61",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"dotenv": "16.4.1",
|
||||
"dotenv": "16.4.5",
|
||||
"eslint-config-people": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jest": "29.7.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -3,16 +3,16 @@ import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import Page from '../page';
|
||||
import Page from '../pages';
|
||||
|
||||
describe('Page', () => {
|
||||
it('checks Page rendering', () => {
|
||||
render(<Page />, { wrapper: AppWrapper });
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
|
||||
'Hello Desk !',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /Create a new team/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
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/';
|
||||
import { useAuthStore } from '@/features/auth';
|
||||
|
||||
describe('fetchAPI', () => {
|
||||
beforeEach(() => {
|
||||
@@ -34,10 +34,25 @@ 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/',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
import { useAuthStore } from '@/features/';
|
||||
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();
|
||||
}
|
||||
|
||||
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
|
||||
response.status === 401 && logout();
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
// Fix - force re-logging the user, will be refactored
|
||||
login();
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './APIError';
|
||||
export * from './fetchApi';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Contacts() {
|
||||
return <Box>Contacts</Box>;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Favorite() {
|
||||
return <Box>Favorite</Box>;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
@import url('../cunningham/cunningham-style.css');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Groups() {
|
||||
return <Box>Groups</Box>;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Help() {
|
||||
return <Box>Help</Box>;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Recent() {
|
||||
return <Box>Recent</Box>;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1020 B After Width: | Height: | Size: 1020 B |
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -1,7 +1,9 @@
|
||||
import { ReactHTML } from 'react';
|
||||
import { ComponentPropsWithRef, 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'];
|
||||
@@ -10,17 +12,24 @@ 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};`}
|
||||
@@ -30,8 +39,19 @@ 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;
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
`;
|
||||
@@ -0,0 +1,55 @@
|
||||
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>;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
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,6 +14,7 @@ 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?:
|
||||
@@ -37,7 +38,10 @@ 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 &&
|
||||
@@ -53,7 +57,7 @@ export const Text = ({
|
||||
return (
|
||||
<TextStyled
|
||||
as="span"
|
||||
$theme="primary"
|
||||
$theme="greyscale"
|
||||
$variation="text"
|
||||
{...props}
|
||||
></TextStyled>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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,2 +1,5 @@
|
||||
export * from './Box';
|
||||
export * from './Text';
|
||||
export * from './Link';
|
||||
export * from './TextErrors';
|
||||
export * from './Card';
|
||||
|
||||
@@ -25,7 +25,4 @@
|
||||
--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-500);
|
||||
color: var(--c--theme--colors--primary-text);
|
||||
}
|
||||
|
||||
.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--theme--colors--primary-500) 0 0 0 2px;
|
||||
box-shadow: var(--c--components--forms-input--box-shadow-color) 0 0 0 2px;
|
||||
}
|
||||
|
||||
.c__textarea__wrapper--disabled:hover,
|
||||
@@ -120,7 +120,16 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
.c__select:not(.c__select--disabled) .c__select__wrapper:hover {
|
||||
box-shadow: var(--c--theme--colors--primary-500) 0 0 0 2px;
|
||||
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);
|
||||
}
|
||||
|
||||
.c__select__menu__item {
|
||||
@@ -135,7 +144,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 {
|
||||
@@ -173,28 +182,60 @@ input:-webkit-autofill:focus {
|
||||
/**
|
||||
* DataGrid
|
||||
*/
|
||||
.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 {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.c__datagrid > table th .c__datagrid__header {
|
||||
color: var(--c--theme--colors--primary-500);
|
||||
.c__datagrid__table__container > table th .c__datagrid__header {
|
||||
color: var(--c--components--datagrid--header--color);
|
||||
font-weight: var(--c--components--datagrid--header--weight);
|
||||
font-size: var(--c--components--datagrid--header--size);
|
||||
padding-block: 2rem;
|
||||
}
|
||||
|
||||
.c__datagrid > table tbody tr {
|
||||
border: 1px var(--c--theme--colors--primary-100) solid;
|
||||
.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 > .c__pagination {
|
||||
padding-top: 1rem;
|
||||
padding-right: 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;
|
||||
@@ -272,6 +313,13 @@ 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(
|
||||
@@ -318,6 +366,7 @@ 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,6 +69,8 @@
|
||||
--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;
|
||||
@@ -158,12 +160,18 @@
|
||||
--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
|
||||
);
|
||||
@@ -313,6 +321,7 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -333,10 +342,11 @@
|
||||
--c--theme--colors--secondary-700: #3b2424;
|
||||
--c--theme--colors--secondary-800: #341f1f;
|
||||
--c--theme--colors--secondary-900: #2b1919;
|
||||
--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-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-400: #ddd;
|
||||
--c--theme--colors--greyscale-500: #cecece;
|
||||
--c--theme--colors--greyscale-600: #7b7b7b;
|
||||
@@ -386,15 +396,70 @@
|
||||
--c--theme--font--families--accent: marianne;
|
||||
--c--theme--font--families--base: marianne;
|
||||
--c--components--alert--border-radius: 0;
|
||||
--c--components--button--border-radius: 2px;
|
||||
--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--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 {
|
||||
@@ -677,6 +742,14 @@
|
||||
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);
|
||||
}
|
||||
@@ -965,6 +1038,14 @@
|
||||
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,6 +73,8 @@ export const tokens = {
|
||||
'success-text': '#FFFFFF',
|
||||
'warning-text': '#FFFFFF',
|
||||
'danger-text': '#FFFFFF',
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'primary-150': '#E5EEFA',
|
||||
'info-150': '#E5EEFA',
|
||||
},
|
||||
@@ -181,11 +183,14 @@ 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)',
|
||||
@@ -321,6 +326,7 @@ export const tokens = {
|
||||
dsfr: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-text': '#000091',
|
||||
'primary-100': '#f5f5fe',
|
||||
'primary-200': '#ececfe',
|
||||
@@ -341,10 +347,11 @@ export const tokens = {
|
||||
'secondary-700': '#3b2424',
|
||||
'secondary-800': '#341f1f',
|
||||
'secondary-900': '#2b1919',
|
||||
'greyscale-000': '#cecece',
|
||||
'greyscale-100': '#f6f6f6',
|
||||
'greyscale-200': '#eeeeee',
|
||||
'greyscale-300': '#e5e5e5',
|
||||
'greyscale-text': '#303C4B',
|
||||
'greyscale-000': '#f6f6f6',
|
||||
'greyscale-100': '#eeeeee',
|
||||
'greyscale-200': '#e5e5e5',
|
||||
'greyscale-300': '#e1e1e1',
|
||||
'greyscale-400': '#dddddd',
|
||||
'greyscale-500': '#cecece',
|
||||
'greyscale-600': '#7b7b7b',
|
||||
@@ -396,17 +403,66 @@ export const tokens = {
|
||||
},
|
||||
components: {
|
||||
alert: { 'border-radius': '0' },
|
||||
button: { 'border-radius': '2px' },
|
||||
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)',
|
||||
},
|
||||
},
|
||||
'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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,9 +20,5 @@ 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,15 +6,11 @@ import { Box } from '@/components';
|
||||
import { useAuthStore } from './useAuthStore';
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { initAuth, authenticated, initialized } = useAuthStore();
|
||||
const { authenticated, initAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
initAuth();
|
||||
}, [initAuth, initialized]);
|
||||
}, [initAuth]);
|
||||
|
||||
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,46 +2,38 @@ 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: () =>
|
||||
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
|
||||
initAuth: () => {
|
||||
getMe()
|
||||
.then((data: UserData) => {
|
||||
set({ authenticated: true, userData: data });
|
||||
})
|
||||
.catch(() => {
|
||||
// todo - implement a proper login screen to prevent automatic navigation.
|
||||
login();
|
||||
});
|
||||
},
|
||||
logout: () => {
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -42,18 +41,23 @@ export const Header = () => {
|
||||
return (
|
||||
<StyledHeader>
|
||||
<RedStripe />
|
||||
<Box className="ml-bx mr-bx" $align="center" $justify="space-between">
|
||||
<Box $direction="column">
|
||||
<Box
|
||||
className="ml-bx mr-bx"
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
$direction="row"
|
||||
>
|
||||
<Box>
|
||||
<Image priority src={IconMarianne} alt={t('Marianne Logo')} />
|
||||
<Box $align="center" $gap="6rem">
|
||||
<Box $align="center" $gap="6rem" $direction="row">
|
||||
<Image
|
||||
priority
|
||||
src={IconGouv}
|
||||
alt={t('Freedom Equality Fraternity Logo')}
|
||||
/>
|
||||
<Box $align="center" $gap="1rem">
|
||||
<Box $align="center" $gap="1rem" $direction="row">
|
||||
<Image priority src={IconDesk} alt={t('Desk Logo')} />
|
||||
<Text className="m-0" as="h2">
|
||||
<Text className="m-0" as="h2" $theme="primary">
|
||||
{t('Desk')}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -68,27 +72,22 @@ export const Header = () => {
|
||||
`}
|
||||
$gap="5rem"
|
||||
$justify="flex-end"
|
||||
$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>
|
||||
<Box $align="center" $direction="row">
|
||||
<LanguagePicker />
|
||||
</Box>
|
||||
<Box>
|
||||
<Box $direction="row">
|
||||
<Box $direction="row" $align="center" $gap="1rem">
|
||||
<Text $weight="bold">John Doe</Text>
|
||||
<Text $weight="bold" $theme="primary">
|
||||
John Doe
|
||||
</Text>
|
||||
<Image
|
||||
width={58}
|
||||
height={58}
|
||||
priority
|
||||
src={IconMyAccount}
|
||||
alt={t(`Profile picture`)}
|
||||
unoptimized
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 663 B |
@@ -1,5 +0,0 @@
|
||||
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>{lang.toUpperCase()}</Text>
|
||||
<Text $theme="primary">{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,9 +21,7 @@ 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,24 +1,14 @@
|
||||
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 } from '@/components/';
|
||||
import { Box, StyledLink } 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;
|
||||
@@ -29,7 +19,7 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const buttonRef = useRef(null);
|
||||
const parentRef = useRef(null);
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
|
||||
const isActive = pathname === href;
|
||||
@@ -51,9 +41,10 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
|
||||
<StyledLink
|
||||
href={href}
|
||||
aria-current={isActive && 'page'}
|
||||
ref={buttonRef}
|
||||
ref={parentRef}
|
||||
onMouseOver={() => setIsTooltipOpen(true)}
|
||||
onMouseOut={() => setIsTooltipOpen(false)}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<Box
|
||||
className="m-st p-t"
|
||||
@@ -86,7 +77,7 @@ const MenuItem = ({ Icon, label, href }: MenuItemProps) => {
|
||||
</Box>
|
||||
{isTooltipOpen && (
|
||||
<Tooltip
|
||||
buttonRef={buttonRef}
|
||||
parentRef={parentRef}
|
||||
label={label}
|
||||
backgroundColor={backgroundTooltip}
|
||||
textColor={colorTooltip}
|
||||
|
||||
@@ -4,14 +4,14 @@ import React, { CSSProperties, useEffect, useState } from 'react';
|
||||
import { Box, Text } from '@/components/';
|
||||
|
||||
interface TooltipProps {
|
||||
buttonRef: React.MutableRefObject<null>;
|
||||
parentRef: React.MutableRefObject<null>;
|
||||
textColor: CSSProperties['color'];
|
||||
backgroundColor: CSSProperties['color'];
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const Tooltip = ({
|
||||
buttonRef,
|
||||
parentRef,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
label,
|
||||
@@ -23,7 +23,7 @@ export const Tooltip = ({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover parentRef={buttonRef} onClickOutside={() => ''} borderless>
|
||||
<Popover parentRef={parentRef} onClickOutside={() => ''} borderless>
|
||||
<Box
|
||||
aria-label="tooltip"
|
||||
className="ml-t p-t"
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +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 { 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './types';
|
||||
export * from './useCreateTeam';
|
||||
export * from './useTeam';
|
||||
export * from './useTeams';
|
||||
export * from './useTeamsAccesses';
|
||||
@@ -0,0 +1,25 @@
|
||||
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 { fetchAPI } from '@/api';
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { KEY_LIST_TEAM } from './useTeams';
|
||||
|
||||
@@ -8,11 +8,8 @@ type CreateTeamResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
export interface CreateTeamResponseError {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const createTeam = async (name: string) => {
|
||||
export const createTeam = async (name: string): Promise<CreateTeamResponse> => {
|
||||
const response = await fetchAPI(`teams/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -21,21 +18,28 @@ export const createTeam = async (name: string) => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Couldn't create team: ${response.statusText}`);
|
||||
throw new APIError(
|
||||
'Failed to create the team',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return response.json() as Promise<CreateTeamResponse>;
|
||||
};
|
||||
|
||||
export function useCreateTeam() {
|
||||
interface CreateTeamProps {
|
||||
onSuccess: (data: CreateTeamResponse) => void;
|
||||
}
|
||||
|
||||
export function useCreateTeam({ onSuccess }: CreateTeamProps) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<CreateTeamResponse, CreateTeamResponseError, string>({
|
||||
return useMutation<CreateTeamResponse, APIError, string>({
|
||||
mutationFn: createTeam,
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_TEAM],
|
||||
exact: true,
|
||||
});
|
||||
onSuccess(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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,38 +1,71 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
DefinedInitialDataInfiniteOptions,
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIList, fetchAPI } from '@/api';
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
interface TeamResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
import { Team } from './types';
|
||||
|
||||
export enum TeamsOrdering {
|
||||
BY_CREATED_ON = 'created_at',
|
||||
BY_CREATED_ON_DESC = '-created_at',
|
||||
}
|
||||
|
||||
type TeamsResponse = APIList<TeamResponse>;
|
||||
export interface TeamsResponseError {
|
||||
detail: string;
|
||||
}
|
||||
export type TeamsParams = {
|
||||
ordering: TeamsOrdering;
|
||||
};
|
||||
type TeamsAPIParams = TeamsParams & {
|
||||
page: number;
|
||||
};
|
||||
|
||||
export const getTeams = async () => {
|
||||
const response = await fetchAPI(`teams/`);
|
||||
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}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Couldn't fetch teams: ${response.statusText}`);
|
||||
throw new APIError('Failed to get the teams', await errorCauses(response));
|
||||
}
|
||||
return response.json();
|
||||
|
||||
return response.json() as Promise<TeamsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_TEAM = 'teams';
|
||||
|
||||
export function useTeams(
|
||||
queryConfig?: UseQueryOptions<
|
||||
param: TeamsParams,
|
||||
queryConfig?: DefinedInitialDataInfiniteOptions<
|
||||
TeamsResponse,
|
||||
TeamsResponseError,
|
||||
TeamsResponse
|
||||
APIError,
|
||||
InfiniteData<TeamsResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
) {
|
||||
return useQuery<TeamsResponse, TeamsResponseError, TeamsResponse>({
|
||||
queryKey: [KEY_LIST_TEAM],
|
||||
queryFn: getTeams,
|
||||
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;
|
||||
},
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 578 B |
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -0,0 +1,89 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
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')}
|
||||
<Text $weight="bold">{created_at}</Text>
|
||||
</Text>
|
||||
<Text $size="s" $direction="row">
|
||||
{t('Last update at')}
|
||||
<Text $weight="bold">{updated_at}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './Panel/Panel';
|
||||
export * from './TeamInfo';
|
||||
export * from './Member/MemberGrid';
|
||||
@@ -0,0 +1 @@
|
||||
export const PAGE_SIZE = 20;
|
||||