diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ec2afc7f2549..b3e4d745b429 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.143.0" + ".": "2.144.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b4bb5c4340..6356f26344aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [2.144.0](https://github.com/Flagsmith/flagsmith/compare/v2.143.0...v2.144.0) (2024-10-03) + + +### Features + +* Identity alias ([#4620](https://github.com/Flagsmith/flagsmith/issues/4620)) ([d18049b](https://github.com/Flagsmith/flagsmith/commit/d18049bc3abd6ac49334fb041bc8fca4778ed198)) +* Improve segment override UI ([#4633](https://github.com/Flagsmith/flagsmith/issues/4633)) ([a265d74](https://github.com/Flagsmith/flagsmith/commit/a265d7475aa80d00b10c7a939918741f0c64040e)) +* Introduce the SDK Evaluation Context schema ([#4414](https://github.com/Flagsmith/flagsmith/issues/4414)) ([d6c6004](https://github.com/Flagsmith/flagsmith/commit/d6c6004f0f514ba997c4c46797c58900dfc5a4e1)) +* Manage tags permission ([#4615](https://github.com/Flagsmith/flagsmith/issues/4615)) ([b3da659](https://github.com/Flagsmith/flagsmith/commit/b3da6594e11d453d9dde4a80b443f9c23fe226ab)) +* Support cookie authentication ([#4662](https://github.com/Flagsmith/flagsmith/issues/4662)) ([e65c8da](https://github.com/Flagsmith/flagsmith/commit/e65c8da425dd241d445361482ab3c12c4b97b0a6)) + + +### Bug Fixes + +* always store and search dashboard alias in lower case ([#4676](https://github.com/Flagsmith/flagsmith/issues/4676)) ([22a3083](https://github.com/Flagsmith/flagsmith/commit/22a30831d0452f94dfd02f1c90e1e22a8c3249f3)) +* **ci:** Rate-limited Trivy database pulls ([#4677](https://github.com/Flagsmith/flagsmith/issues/4677)) ([4bca509](https://github.com/Flagsmith/flagsmith/commit/4bca50909892b5f22b0042d4defa914581c65f02)) +* Clear project org search on close ([#4690](https://github.com/Flagsmith/flagsmith/issues/4690)) ([b4b48b7](https://github.com/Flagsmith/flagsmith/commit/b4b48b75c0006b667edd65422392151689bd3758)) +* encode identity search ([#4691](https://github.com/Flagsmith/flagsmith/issues/4691)) ([0485601](https://github.com/Flagsmith/flagsmith/commit/0485601f5a86359d4cc8c3cb765fce3e76184199)) +* encode search when querying features ([#4689](https://github.com/Flagsmith/flagsmith/issues/4689)) ([7c746f4](https://github.com/Flagsmith/flagsmith/commit/7c746f42f23b6b0cc2013282c05bd2b46ef85b75)) +* ensure MANAGE_TAGS permission allows create tag ([#4678](https://github.com/Flagsmith/flagsmith/issues/4678)) ([58eb9ed](https://github.com/Flagsmith/flagsmith/commit/58eb9ed8140d1a73a4d36c4f297d1f8d3162808a)) +* feature specific segment ([#4682](https://github.com/Flagsmith/flagsmith/issues/4682)) ([a867ed1](https://github.com/Flagsmith/flagsmith/commit/a867ed15204ea75029b65474c8aef41b320ec49d)) +* Handle cancellation date for api usage ([#4672](https://github.com/Flagsmith/flagsmith/issues/4672)) ([17be366](https://github.com/Flagsmith/flagsmith/commit/17be366a02f5f8ac4379f1936e4ab2af7284720c)) +* remove trait ([#4686](https://github.com/Flagsmith/flagsmith/issues/4686)) ([6dc8b7b](https://github.com/Flagsmith/flagsmith/commit/6dc8b7b4a52a32fd7f9de63ab21a129050829f19)) +* update permissions on classes with missing / unclear permissions ([#4667](https://github.com/Flagsmith/flagsmith/issues/4667)) ([19026e4](https://github.com/Flagsmith/flagsmith/commit/19026e4ced349b02f6863f7669bdf29f162141fa)) + ## [2.143.0](https://github.com/Flagsmith/flagsmith/compare/v2.142.0...v2.143.0) (2024-09-27) diff --git a/api/Makefile b/api/Makefile index 947036d908b9..44863d3cf51b 100644 --- a/api/Makefile +++ b/api/Makefile @@ -11,7 +11,7 @@ POETRY_VERSION ?= 1.8.3 GUNICORN_LOGGER_CLASS ?= util.logging.GunicornJsonCapableLogger -SAML_REVISION ?= v1.6.3 +SAML_REVISION ?= v1.6.4 RBAC_REVISION ?= v0.8.0 -include .env-local diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 1a8067d80cfd..aafd4afea939 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -94,6 +94,7 @@ "rest_framework.authtoken", # Used for managing api keys "rest_framework_api_key", + "rest_framework_simplejwt.token_blacklist", "djoser", "django.contrib.sites", "custom_auth", @@ -254,6 +255,7 @@ REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], "DEFAULT_AUTHENTICATION_CLASSES": ( + "custom_auth.jwt_cookie.authentication.JWTCookieAuthentication", "rest_framework.authentication.TokenAuthentication", "api_keys.authentication.MasterAPIKeyAuthentication", ), @@ -416,19 +418,6 @@ MEDIA_URL = "/media/" # unused but needs to be different from STATIC_URL in django 3 -# CORS settings - -CORS_ORIGIN_ALLOW_ALL = True -FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS = env.list( - "FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS", default=["sentry-trace"] -) -CORS_ALLOW_HEADERS = [ - *default_headers, - *FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS, - "X-Environment-Key", - "X-E2E-Test-Auth-Token", -] - DEFAULT_FROM_EMAIL = env("SENDER_EMAIL", default="noreply@flagsmith.com") EMAIL_CONFIGURATION = { # Invitations with name is anticipated to take two arguments. The persons name and the @@ -826,6 +815,16 @@ "user_create": USER_CREATE_PERMISSIONS, }, } +SIMPLE_JWT = { + "AUTH_TOKEN_CLASSES": ["rest_framework_simplejwt.tokens.SlidingToken"], + "SLIDING_TOKEN_LIFETIME": timedelta( + minutes=env.int( + "COOKIE_AUTH_JWT_ACCESS_TOKEN_LIFETIME_MINUTES", + default=10 * 60, + ) + ), + "SIGNING_KEY": env.str("COOKIE_AUTH_JWT_SIGNING_KEY", default=SECRET_KEY), +} # Github OAuth credentials GITHUB_CLIENT_ID = env.str("GITHUB_CLIENT_ID", default="") @@ -907,8 +906,6 @@ SENTRY_API_KEY = env("SENTRY_API_KEY", default=None) AMPLITUDE_API_KEY = env("AMPLITUDE_API_KEY", default=None) ENABLE_FLAGSMITH_REALTIME = env.bool("ENABLE_FLAGSMITH_REALTIME", default=False) -USE_SECURE_COOKIES = env.bool("USE_SECURE_COOKIES", default=True) -COOKIE_SAME_SITE = env.str("COOKIE_SAME_SITE", default="none") # Set this to enable create organisation for only superusers RESTRICT_ORG_CREATE_TO_SUPERUSERS = env.bool("RESTRICT_ORG_CREATE_TO_SUPERUSERS", False) @@ -1038,6 +1035,24 @@ DISABLE_INVITE_LINKS = env.bool("DISABLE_INVITE_LINKS", False) PREVENT_SIGNUP = env.bool("PREVENT_SIGNUP", default=False) +COOKIE_AUTH_ENABLED = env.bool("COOKIE_AUTH_ENABLED", default=False) +USE_SECURE_COOKIES = env.bool("USE_SECURE_COOKIES", default=True) +COOKIE_SAME_SITE = env.str("COOKIE_SAME_SITE", default="none") + +# CORS settings + +CORS_ORIGIN_ALLOW_ALL = env.bool("CORS_ORIGIN_ALLOW_ALL", not COOKIE_AUTH_ENABLED) +CORS_ALLOW_CREDENTIALS = env.bool("CORS_ALLOW_CREDENTIALS", COOKIE_AUTH_ENABLED) +FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS = env.list( + "FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS", default=["sentry-trace"] +) +CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[]) +CORS_ALLOW_HEADERS = [ + *default_headers, + *FLAGSMITH_CORS_EXTRA_ALLOW_HEADERS, + "X-Environment-Key", + "X-E2E-Test-Auth-Token", +] # use a separate boolean setting so that we add it to the API containers in environments # where we're running the task processor, so we avoid creating unnecessary tasks diff --git a/api/app/views.py b/api/app/views.py index ba2b9be9b4c4..830fec912438 100644 --- a/api/app/views.py +++ b/api/app/views.py @@ -37,14 +37,17 @@ def project_overrides(request): "amplitude": "AMPLITUDE_API_KEY", "api": "API_URL", "assetURL": "ASSET_URL", + "cookieAuthEnabled": "COOKIE_AUTH_ENABLED", + "cookieSameSite": "COOKIE_SAME_SITE", "crispChat": "CRISP_CHAT_API_KEY", "disableAnalytics": "DISABLE_ANALYTICS_FEATURES", "flagsmith": "FLAGSMITH_ON_FLAGSMITH_API_KEY", "flagsmithAnalytics": "FLAGSMITH_ANALYTICS", - "flagsmithRealtime": "ENABLE_FLAGSMITH_REALTIME", "flagsmithClientAPI": "FLAGSMITH_ON_FLAGSMITH_API_URL", - "ga": "GOOGLE_ANALYTICS_API_KEY", + "flagsmithRealtime": "ENABLE_FLAGSMITH_REALTIME", "fpr": "FIRST_PROMOTER_ID", + "ga": "GOOGLE_ANALYTICS_API_KEY", + "githubAppURL": "GITHUB_APP_URL", "headway": "HEADWAY_API_KEY", "hideInviteLinks": "DISABLE_INVITE_LINKS", "linkedinPartnerTracking": "LINKEDIN_PARTNER_TRACKING", @@ -54,8 +57,6 @@ def project_overrides(request): "preventSignup": "PREVENT_SIGNUP", "sentry": "SENTRY_API_KEY", "useSecureCookies": "USE_SECURE_COOKIES", - "cookieSameSite": "COOKIE_SAME_SITE", - "githubAppURL": "GITHUB_APP_URL", } override_data = { diff --git a/api/core/helpers.py b/api/core/helpers.py index 3af9a067664d..4f327e5a8556 100644 --- a/api/core/helpers.py +++ b/api/core/helpers.py @@ -1,7 +1,7 @@ import re from django.conf import settings -from django.contrib.sites.models import Site +from django.contrib.sites import models as sites_models from django.http import HttpRequest from rest_framework.request import Request @@ -11,12 +11,18 @@ def get_current_site_url(request: HttpRequest | Request | None = None) -> str: - if settings.DOMAIN_OVERRIDE: - domain = settings.DOMAIN_OVERRIDE - elif current_site := Site.objects.filter(id=settings.SITE_ID).first(): - domain = current_site.domain - else: - domain = settings.DEFAULT_DOMAIN + if not (domain := settings.DOMAIN_OVERRIDE): + try: + domain = sites_models.Site.objects.get_current(request).domain + except sites_models.Site.DoesNotExist: + # For the rare case when `DOMAIN_OVERRIDE` was not set and no `Site` object present, + # store a default domain `Site` in the sites cache + # so it's correctly invalidated should the user decide to create own `Site` object. + domain = settings.DEFAULT_DOMAIN + sites_models.SITE_CACHE[settings.SITE_ID] = sites_models.Site( + name="Flagsmith", + domain=domain, + ) if request: scheme = request.scheme diff --git a/api/custom_auth/apps.py b/api/custom_auth/apps.py index 2328a449d114..005927503540 100644 --- a/api/custom_auth/apps.py +++ b/api/custom_auth/apps.py @@ -6,3 +6,4 @@ class CustomAuthAppConfig(AppConfig): def ready(self) -> None: from custom_auth import tasks # noqa F401 + from custom_auth.jwt_cookie import signals # noqa F401 diff --git a/api/custom_auth/jwt_cookie/__init__.py b/api/custom_auth/jwt_cookie/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/custom_auth/jwt_cookie/authentication.py b/api/custom_auth/jwt_cookie/authentication.py new file mode 100644 index 000000000000..379617b1fa10 --- /dev/null +++ b/api/custom_auth/jwt_cookie/authentication.py @@ -0,0 +1,17 @@ +from rest_framework.request import Request +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.tokens import Token + +from custom_auth.jwt_cookie.constants import JWT_SLIDING_COOKIE_KEY +from users.models import FFAdminUser + + +class JWTCookieAuthentication(JWTAuthentication): + def authenticate_header(self, request: Request) -> str: + return f'Cookie realm="{self.www_authenticate_realm}"' + + def authenticate(self, request: Request) -> tuple[FFAdminUser, Token] | None: + if raw_token := request.COOKIES.get(JWT_SLIDING_COOKIE_KEY): + validated_token = self.get_validated_token(raw_token) + return self.get_user(validated_token), validated_token + return None diff --git a/api/custom_auth/jwt_cookie/constants.py b/api/custom_auth/jwt_cookie/constants.py new file mode 100644 index 000000000000..340700b867b4 --- /dev/null +++ b/api/custom_auth/jwt_cookie/constants.py @@ -0,0 +1 @@ +JWT_SLIDING_COOKIE_KEY = "jwt" diff --git a/api/custom_auth/jwt_cookie/services.py b/api/custom_auth/jwt_cookie/services.py new file mode 100644 index 000000000000..eb7796309272 --- /dev/null +++ b/api/custom_auth/jwt_cookie/services.py @@ -0,0 +1,18 @@ +from django.conf import settings +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import SlidingToken + +from custom_auth.jwt_cookie.constants import JWT_SLIDING_COOKIE_KEY +from users.models import FFAdminUser + + +def authorise_response(user: FFAdminUser, response: Response) -> Response: + sliding_token = SlidingToken.for_user(user) + response.set_cookie( + JWT_SLIDING_COOKIE_KEY, + str(sliding_token), + httponly=True, + secure=settings.USE_SECURE_COOKIES, + samesite=settings.COOKIE_SAME_SITE, + ) + return response diff --git a/api/custom_auth/jwt_cookie/signals.py b/api/custom_auth/jwt_cookie/signals.py new file mode 100644 index 000000000000..1e840eb681d7 --- /dev/null +++ b/api/custom_auth/jwt_cookie/signals.py @@ -0,0 +1,20 @@ +from typing import Any +from urllib.parse import urlparse + +from core.helpers import get_current_site_url +from corsheaders.signals import check_request_enabled +from django.dispatch import receiver +from django.http import HttpRequest + + +@receiver(check_request_enabled) +def cors_allow_current_site(request: HttpRequest, **kwargs: Any) -> bool: + # The signal is expected to only be dispatched: + # - When `settings.CORS_ORIGIN_ALLOW_ALL` is set to `False`. + # - For requests with `HTTP_ORIGIN` set. + origin_url = urlparse(request.META["HTTP_ORIGIN"]) + current_site_url = urlparse(get_current_site_url(request)) + return ( + origin_url.scheme == current_site_url.scheme + and origin_url.netloc == current_site_url.netloc + ) diff --git a/api/custom_auth/jwt_cookie/views.py b/api/custom_auth/jwt_cookie/views.py new file mode 100644 index 000000000000..677df48ccaa4 --- /dev/null +++ b/api/custom_auth/jwt_cookie/views.py @@ -0,0 +1,15 @@ +from djoser.views import TokenDestroyView +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import SlidingToken + +from custom_auth.jwt_cookie.constants import JWT_SLIDING_COOKIE_KEY + + +class JWTSlidingTokenLogoutView(TokenDestroyView): + def post(self, request: Request) -> Response: + response = super().post(request) + if isinstance(jwt_token := request.auth, SlidingToken): + jwt_token.blacklist() + response.delete_cookie(JWT_SLIDING_COOKIE_KEY) + return response diff --git a/api/custom_auth/serializers.py b/api/custom_auth/serializers.py index c4176a86c9ca..d1322c1d50be 100644 --- a/api/custom_auth/serializers.py +++ b/api/custom_auth/serializers.py @@ -1,3 +1,5 @@ +from typing import Any + from django.conf import settings from djoser.conf import settings as djoser_settings from djoser.serializers import TokenCreateSerializer, UserCreateSerializer @@ -73,13 +75,15 @@ def _validate_registration_invite(self, email: str, sign_up_type: str) -> None: class CustomUserCreateSerializer(UserCreateSerializer, InviteLinkValidationMixin): - key = serializers.SerializerMethodField() + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + if not settings.COOKIE_AUTH_ENABLED: + self.fields["key"] = serializers.SerializerMethodField() class Meta(UserCreateSerializer.Meta): fields = UserCreateSerializer.Meta.fields + ( "is_active", "marketing_consent_given", - "key", "uuid", ) read_only_fields = ("is_active", "uuid") @@ -115,8 +119,14 @@ def validate(self, attrs): attrs["email"] = email.lower() return attrs + def save(self) -> FFAdminUser: + instance = super().save() + if "view" in self.context: + self.context["view"].user = instance + return instance + @staticmethod - def get_key(instance): + def get_key(instance) -> str: token, _ = Token.objects.get_or_create(user=instance) return token.key diff --git a/api/custom_auth/urls.py b/api/custom_auth/urls.py index 54995ad5def7..1494567fc698 100644 --- a/api/custom_auth/urls.py +++ b/api/custom_auth/urls.py @@ -1,7 +1,7 @@ from django.urls import include, path -from djoser.views import TokenDestroyView from rest_framework.routers import DefaultRouter +from custom_auth.jwt_cookie.views import JWTSlidingTokenLogoutView from custom_auth.views import ( CustomAuthTokenLoginOrRequestMFACode, CustomAuthTokenLoginWithMFACode, @@ -26,7 +26,11 @@ CustomAuthTokenLoginWithMFACode.as_view(), name="mfa-authtoken-login-code", ), - path("logout/", TokenDestroyView.as_view(), name="authtoken-logout"), + path( + "logout/", + JWTSlidingTokenLogoutView.as_view(), + name="jwt-logout", + ), path("", include(ffadmin_user_router.urls)), path("token/", delete_token, name="delete-token"), # NOTE: endpoints provided by `djoser.urls` diff --git a/api/custom_auth/views.py b/api/custom_auth/views.py index 97381120fd93..5cc5deedbf55 100644 --- a/api/custom_auth/views.py +++ b/api/custom_auth/views.py @@ -1,3 +1,5 @@ +from typing import Any + from django.conf import settings from django.contrib.auth import user_logged_out from django.utils.decorators import method_decorator @@ -9,8 +11,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.status import HTTP_204_NO_CONTENT from rest_framework.throttling import ScopedRateThrottle +from custom_auth.jwt_cookie.services import authorise_response from custom_auth.mfa.backends.application import CustomApplicationBackend from custom_auth.mfa.trench.command.authenticate_second_factor import ( authenticate_second_step_command, @@ -34,6 +38,7 @@ class CustomAuthTokenLoginOrRequestMFACode(TokenCreateView): Class to handle throttling for login requests """ + authentication_classes = [] throttle_classes = [ScopedRateThrottle] throttle_scope = "login" @@ -54,6 +59,8 @@ def post(self, request: Request) -> Response: } ) except MFAMethodDoesNotExistError: + if settings.COOKIE_AUTH_ENABLED: + return authorise_response(user, Response(status=HTTP_204_NO_CONTENT)) return self._action(serializer) @@ -62,6 +69,7 @@ class CustomAuthTokenLoginWithMFACode(TokenCreateView): Override class to add throttling """ + authentication_classes = [] throttle_classes = [ScopedRateThrottle] throttle_scope = "mfa_code" @@ -74,6 +82,8 @@ def post(self, request: Request) -> Response: ephemeral_token=serializer.validated_data["ephemeral_token"], ) serializer.user = user + if settings.COOKIE_AUTH_ENABLED: + return authorise_response(user, Response(status=HTTP_204_NO_CONTENT)) return self._action(serializer) except MFAValidationError as cause: return ErrorResponse(error=cause, status=status.HTTP_401_UNAUTHORIZED) @@ -96,6 +106,11 @@ def delete_token(request): class FFAdminUserViewSet(UserViewSet): throttle_scope = "signup" + def perform_authentication(self, request: Request) -> None: + if self.action == "create": + return + return super().perform_authentication(request) + def get_throttles(self): """ Used for throttling create(signup) action @@ -105,6 +120,12 @@ def get_throttles(self): throttles = [ScopedRateThrottle()] return throttles + def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: + response = super().create(request, *args, **kwargs) + if settings.COOKIE_AUTH_ENABLED: + authorise_response(self.user, response) + return response + def perform_destroy(self, instance): instance.delete( delete_orphan_organisations=self.request.data.get( diff --git a/api/edge_api/identities/serializers.py b/api/edge_api/identities/serializers.py index fa1bc4897d26..72dd73d58262 100644 --- a/api/edge_api/identities/serializers.py +++ b/api/edge_api/identities/serializers.py @@ -285,11 +285,14 @@ class GetEdgeIdentityOverridesQuerySerializer(serializers.Serializer): class EdgeIdentitySearchField(serializers.CharField): def to_internal_value(self, data: str) -> EdgeIdentitySearchData: kwargs = {} - search_term = data.lower() + search_term = data if search_term.startswith(DASHBOARD_ALIAS_SEARCH_PREFIX): kwargs["search_attribute"] = DASHBOARD_ALIAS_ATTRIBUTE - search_term = search_term.lstrip(DASHBOARD_ALIAS_SEARCH_PREFIX) + # dashboard aliases are always stored in lower case + search_term = search_term.removeprefix( + DASHBOARD_ALIAS_SEARCH_PREFIX + ).lower() else: kwargs["search_attribute"] = IDENTIFIER_ATTRIBUTE diff --git a/api/poetry.lock b/api/poetry.lock index 1296bf8fb500..83d165798e50 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1102,27 +1102,27 @@ djangorestframework = ">=3.0" [[package]] name = "djangorestframework-simplejwt" -version = "5.2.2" +version = "5.3.1" description = "A minimal JSON Web Token authentication plugin for Django REST Framework" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "djangorestframework_simplejwt-5.2.2-py3-none-any.whl", hash = "sha256:4c0d2e2513e12587d93501ac091781684a216c3ee614eb3b5a10586aef5ca845"}, - {file = "djangorestframework_simplejwt-5.2.2.tar.gz", hash = "sha256:d27d4bcac2c6394f678dea8b4d0d511c6e18a7f2eb8aaeeb8a7de601aeb77c42"}, + {file = "djangorestframework_simplejwt-5.3.1-py3-none-any.whl", hash = "sha256:381bc966aa46913905629d472cd72ad45faa265509764e20ffd440164c88d220"}, + {file = "djangorestframework_simplejwt-5.3.1.tar.gz", hash = "sha256:6c4bd37537440bc439564ebf7d6085e74c5411485197073f508ebdfa34bc9fae"}, ] [package.dependencies] -django = "*" -djangorestframework = "*" +django = ">=3.2" +djangorestframework = ">=3.12" pyjwt = ">=1.7.1,<3" [package.extras] crypto = ["cryptography (>=3.3.1)"] -dev = ["Sphinx (>=1.6.5,<2)", "cryptography", "flake8", "ipython", "isort", "pep8", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "sphinx-rtd-theme (>=0.1.9)", "tox", "twine", "wheel"] -doc = ["Sphinx (>=1.6.5,<2)", "sphinx-rtd-theme (>=0.1.9)"] +dev = ["Sphinx (>=1.6.5,<2)", "cryptography", "flake8", "freezegun", "ipython", "isort", "pep8", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel"] +doc = ["Sphinx (>=1.6.5,<2)", "sphinx_rtd_theme (>=0.1.9)"] lint = ["flake8", "isort", "pep8"] python-jose = ["python-jose (==3.3.0)"] -test = ["cryptography", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] +test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] [[package]] name = "djoser" @@ -2145,6 +2145,16 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -3315,6 +3325,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -3322,8 +3333,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -3340,6 +3358,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -3347,6 +3366,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -4161,4 +4181,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "80004eaae4f8296e4ce3f3f7459db21bd73c1f29c675e5de0ff157322715f882" +content-hash = "39e82dd19f6474cd680ee9b42b36c2a3018b8dd1704e03fbdebdabdbaf620a96" diff --git a/api/pyproject.toml b/api/pyproject.toml index ed4e4a0b1978..57b24ea7a734 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -172,6 +172,7 @@ pyotp = "^2.9.0" flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task-processor", tag = "v1.0.2" } flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.0.0" } tzdata = "^2024.1" +djangorestframework-simplejwt = "^5.3.1" [tool.poetry.group.auth-controller] optional = true diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index b5d79c20155c..7afc966ccdb8 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -375,6 +375,9 @@ def identity_document( mv_feature_name, mv_feature, ): + _identifier = "User1-Test" # use a mixture of cases and symbols to make sure we're testing all cases + _dashboard_alias = "dashboard-alias" + _environment_feature_state_1_document = { "featurestate_uuid": "ad71c644-71df-4e83-9cb5-cd2cd0160200", "multivariate_feature_state_values": [], @@ -431,15 +434,15 @@ def identity_document( "feature_segment": None, } return { - "composite_key": f"{environment_api_key}_user_1_test", - "dashboard_alias": "dashboard-alias", + "composite_key": f"{environment_api_key}_{_identifier}", + "dashboard_alias": _dashboard_alias, "identity_traits": identity_traits, "identity_features": [ _environment_feature_state_1_document, _environment_feature_state_2_document, _mv_feature_state_document, ], - "identifier": "user_1_test", + "identifier": _identifier, "created_date": "2021-09-21T10:12:42.230257+00:00", "environment_api_key": environment_api_key, "identity_uuid": "59efa2a7-6a45-46d6-b953-a7073a90eacf", diff --git a/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py b/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py index e274475119b5..272d6e6750d9 100644 --- a/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py +++ b/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py @@ -9,6 +9,7 @@ from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient, override_settings +from rest_framework_simplejwt.tokens import SlidingToken from organisations.invites.models import Invite from organisations.models import Organisation @@ -286,6 +287,197 @@ def test_login_workflow_with_mfa_enabled( assert current_user_response.json()["email"] == email +@override_settings(COOKIE_AUTH_ENABLED=True) +def test_register_and_login_workflows__jwt_cookie( + db: None, + api_client: APIClient, +) -> None: + # Given + email = "test@example.com" + password = FFAdminUser.objects.make_random_password() + register_url = reverse("api-v1:custom_auth:ffadminuser-list") + login_url = reverse("api-v1:custom_auth:custom-mfa-authtoken-login") + logout_url = reverse("api-v1:custom_auth:jwt-logout") + protected_resource_url = reverse("api-v1:projects:project-list") + register_data = { + "first_name": "test", + "last_name": "last_name", + "email": email, + "password": password, + "re_password": password, + } + login_data = { + "email": email, + "password": password, + } + + # When & Then + # verify the cookie is returned on registration + response = api_client.post(register_url, data=register_data) + assert response.status_code == status.HTTP_201_CREATED + assert (jwt_access_cookie := response.cookies.get("jwt")) is not None + assert jwt_access_cookie["httponly"] + + # verify the classic token is not returned on registration + assert "key" not in response.json() + + # verify the register cookie works when accessing a protected endpoint + response = api_client.get( + protected_resource_url, + ) + assert response.status_code == status.HTTP_200_OK + + # now verify we can login with the same credentials + response = api_client.post(login_url, data=login_data) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert (jwt_access_cookie := response.cookies.get("jwt")) is not None + assert jwt_access_cookie["httponly"] + + # verify the classic token is not returned on login + assert not response.data + + # verify the login cookie works when accessing a protected endpoint + response = api_client.get(protected_resource_url) + assert response.status_code == status.HTTP_200_OK + + # logout + response = api_client.post(logout_url) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # verify the login cookie does not work anymore + response = api_client.get(protected_resource_url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + # login again + response = api_client.post(login_url, data=login_data) + assert response.status_code == status.HTTP_204_NO_CONTENT + new_jwt_access_cookie = response.cookies.get("jwt") + + # verify new token is different from the old one + assert new_jwt_access_cookie != jwt_access_cookie + + +@override_settings(COOKIE_AUTH_ENABLED=True) +def test_login_workflow__jwt_cookie__mfa_enabled( + db: None, + api_client: APIClient, +) -> None: + # Given + email = "test@example.com" + password = FFAdminUser.objects.make_random_password() + register_url = reverse("api-v1:custom_auth:ffadminuser-list") + create_mfa_method_url = reverse( + "api-v1:custom_auth:mfa-activate", kwargs={"method": "app"} + ) + login_url = reverse("api-v1:custom_auth:custom-mfa-authtoken-login") + login_confirm_url = reverse("api-v1:custom_auth:mfa-authtoken-login-code") + logout_url = reverse("api-v1:custom_auth:jwt-logout") + register_data = { + "first_name": "test", + "last_name": "last_name", + "email": email, + "password": password, + "re_password": password, + } + login_data = { + "email": email, + "password": password, + } + response = api_client.post(register_url, data=register_data) + jwt_access_cookie = response.cookies.get("jwt") + response = api_client.post(create_mfa_method_url) + secret = response.json()["secret"] + totp = pyotp.TOTP(secret) + confirm_mfa_data = {"code": totp.now()} + confirm_mfa_method_url = reverse( + "api-v1:custom_auth:mfa-activate-confirm", kwargs={"method": "app"} + ) + api_client.post(confirm_mfa_method_url, data=confirm_mfa_data) + api_client.post(logout_url) + + # When & Then + # verify the cookie is returned on login + response = api_client.post(login_url, data=login_data) + ephemeral_token = response.json()["ephemeral_token"] + confirm_login_data = {"ephemeral_token": ephemeral_token, "code": totp.now()} + response = api_client.post(login_confirm_url, data=confirm_login_data) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert (jwt_access_cookie := response.cookies.get("jwt")) is not None + assert jwt_access_cookie["httponly"] + + # verify the classic token is not returned on login + assert not response.data + + +# In the real world, setting `COOKIE_AUTH_ENABLED` to `True` +# changes default CORS setting values. +# Due to how Django settings are loaded for tests, +# we have to override CORS settings manually. +@override_settings( + COOKIE_AUTH_ENABLED=True, + DOMAIN_OVERRIDE="testhost.com", + CORS_ORIGIN_ALLOW_ALL=False, + CORS_ALLOW_CREDENTIALS=True, +) +def test_login_workflow__jwt_cookie__cors_headers_expected( + db: None, + api_client: APIClient, +) -> None: + # Given + email = "test@example.com" + password = FFAdminUser.objects.make_random_password() + register_url = reverse("api-v1:custom_auth:ffadminuser-list") + protected_resource_url = reverse("api-v1:projects:project-list") + register_data = { + "first_name": "test", + "last_name": "last_name", + "email": email, + "password": password, + "re_password": password, + } + api_client.post(register_url, data=register_data) + + # When + response = api_client.get( + protected_resource_url, + HTTP_ORIGIN="http://testhost.com", + ) + + # Then + assert response.headers["Access-Control-Allow-Origin"] == "http://testhost.com" + + +@override_settings(COOKIE_AUTH_ENABLED=True) +def test_login_workflow__jwt_cookie__invalid_token__no_cookies_expected( + db: None, + api_client: APIClient, +) -> None: + # Given + email = "test@example.com" + password = FFAdminUser.objects.make_random_password() + register_url = reverse("api-v1:custom_auth:ffadminuser-list") + protected_resource_url = reverse("api-v1:projects:project-list") + register_data = { + "first_name": "test", + "last_name": "last_name", + "email": email, + "password": password, + "re_password": password, + } + response = api_client.post(register_url, data=register_data) + jwt_access_cookie = response.cookies.get("jwt") + + # cookie is invalidated server-side but is still attached to the client + SlidingToken(jwt_access_cookie.value).blacklist() + + # When + response = api_client.get(protected_resource_url) + + # Then + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert not response.cookies.get("jwt") + + def test_throttle_login_workflows( api_client: APIClient, db: None, diff --git a/api/tests/integration/edge_api/identities/test_edge_identity_viewset.py b/api/tests/integration/edge_api/identities/test_edge_identity_viewset.py index 963516be0f2f..b479c86ef9ce 100644 --- a/api/tests/integration/edge_api/identities/test_edge_identity_viewset.py +++ b/api/tests/integration/edge_api/identities/test_edge_identity_viewset.py @@ -9,12 +9,7 @@ from rest_framework.exceptions import NotFound from rest_framework.test import APIClient -from edge_api.identities.search import ( - DASHBOARD_ALIAS_SEARCH_PREFIX, - IDENTIFIER_ATTRIBUTE, - EdgeIdentitySearchData, - EdgeIdentitySearchType, -) +from edge_api.identities.search import DASHBOARD_ALIAS_SEARCH_PREFIX from environments.dynamodb.wrappers.environment_wrapper import ( DynamoEnvironmentV2Wrapper, ) @@ -259,54 +254,43 @@ def test_get_identities_list( def test_search_identities_without_exact_match( - admin_client, - dynamo_enabled_environment, - environment_api_key, - identity_document, - edge_identity_dynamo_wrapper_mock, + admin_client: APIClient, + dynamo_enabled_environment: Environment, + environment_api_key: str, + identity_document: dict[str, Any], + flagsmith_identities_table: Table, ): # Given identifier = identity_document["identifier"] + flagsmith_identities_table.put_item(Item=identity_document) + base_url = reverse( "api-v1:environments:environment-edge-identities-list", args=[environment_api_key], ) - url = "%s?q=%s" % (base_url, identifier) - edge_identity_dynamo_wrapper_mock.search_items.return_value = { - "Items": [identity_document], - "Count": 1, - } + url = "%s?q=%s" % (base_url, identifier[:6]) # When response = admin_client.get(url) + # Then assert response.status_code == status.HTTP_200_OK - assert response.json()["results"][0]["identifier"] == identifier assert len(response.json()["results"]) == 1 - - edge_identity_dynamo_wrapper_mock.search_items.assert_called_with( - environment_api_key=environment_api_key, - search_data=EdgeIdentitySearchData( - search_term=identifier, - search_type=EdgeIdentitySearchType.BEGINS_WITH, - search_attribute=IDENTIFIER_ATTRIBUTE, - ), - limit=100, - start_key=None, - ) + assert response.json()["results"][0]["identifier"] == identifier def test_search_for_identities_with_exact_match( - admin_client, - dynamo_enabled_environment, - environment_api_key, - identity_document, - edge_identity_dynamo_wrapper_mock, + admin_client: APIClient, + dynamo_enabled_environment: Environment, + environment_api_key: str, + identity_document: dict[str, Any], + flagsmith_identities_table: Table, ): # Given identifier = identity_document["identifier"] + flagsmith_identities_table.put_item(Item=identity_document) base_url = reverse( "api-v1:environments:environment-edge-identities-list", @@ -316,31 +300,56 @@ def test_search_for_identities_with_exact_match( base_url, urllib.parse.urlencode({"q": f'"{identifier}"'}), ) - edge_identity_dynamo_wrapper_mock.search_items.return_value = { - "Items": [identity_document], - "Count": 1, - } # When response = admin_client.get(url) + # Then assert response.status_code == status.HTTP_200_OK - assert response.json()["results"][0]["identifier"] == identifier assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["identifier"] == identifier + + +def test_search_for_identities_by_dashboard_alias_prefix( + admin_client: APIClient, + dynamo_enabled_environment: Environment, + environment_api_key: str, + identity_document: dict[str, Any], + flagsmith_identities_table: Table, +) -> None: + # Given + identifier = identity_document["identifier"] + + # This test verifies a previous bug which meant that if any of the + # leading characters to the search string were contained in the + # `string `"dashboard_alias:"` then they would also be removed + # and the search would fail. + identity_document["dashboard_alias"] = "hans.gruber@example.com" + search_string = "hans" - edge_identity_dynamo_wrapper_mock.search_items.assert_called_with( - environment_api_key=environment_api_key, - search_data=EdgeIdentitySearchData( - search_term=identifier, - search_type=EdgeIdentitySearchType.EQUAL, - search_attribute=IDENTIFIER_ATTRIBUTE, + flagsmith_identities_table.put_item(Item=identity_document) + + base_url = reverse( + "api-v1:environments:environment-edge-identities-list", + args=[environment_api_key], + ) + url = "%s?%s" % ( + base_url, + urllib.parse.urlencode( + {"q": f"{DASHBOARD_ALIAS_SEARCH_PREFIX}{search_string}"} ), - limit=100, - start_key=None, ) + # When + response = admin_client.get(url) -def test_search_for_identities_by_dashboard_alias( + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["identifier"] == identifier + + +def test_search_for_identities_by_dashboard_alias_exact( admin_client: APIClient, dynamo_enabled_environment: Environment, environment_api_key: str, @@ -369,8 +378,8 @@ def test_search_for_identities_by_dashboard_alias( # Then assert response.status_code == status.HTTP_200_OK - assert response.json()["results"][0]["identifier"] == identifier assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["identifier"] == identifier def test_search_for_identities_by_dashboard_alias_casts_search_to_lower( @@ -402,8 +411,8 @@ def test_search_for_identities_by_dashboard_alias_casts_search_to_lower( # Then assert response.status_code == status.HTTP_200_OK - assert response.json()["results"][0]["identifier"] == identifier assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["identifier"] == identifier def test_update_edge_identity( diff --git a/api/tests/unit/core/test_helpers.py b/api/tests/unit/core/test_helpers.py index 03e3455a2714..e57483bd7045 100644 --- a/api/tests/unit/core/test_helpers.py +++ b/api/tests/unit/core/test_helpers.py @@ -5,7 +5,7 @@ from django.contrib.sites.models import Site if typing.TYPE_CHECKING: - from pytest_django.fixtures import SettingsWrapper + from pytest_django.fixtures import DjangoAssertNumQueries, SettingsWrapper from pytest_mock import MockerFixture pytestmark = pytest.mark.django_db @@ -26,13 +26,13 @@ def test_get_current_site_url_returns_correct_url_if_site_exists( assert url == f"https://{expected_domain}" -def test_get_current_site_url_uses_default_url_if_site_does_not_exists( +def test_get_current_site_url_uses_default_url_if_site_does_not_exist( settings: "SettingsWrapper", ) -> None: # Given expected_domain = "some-testing-url.com" settings.DEFAULT_DOMAIN = expected_domain - settings.SITE_ID = None + Site.objects.all().delete() # When url = get_current_site_url() @@ -41,6 +41,35 @@ def test_get_current_site_url_uses_default_url_if_site_does_not_exists( assert url == f"https://{expected_domain}" +def test_get_current_site_url__site_created__cached_return_expected( + settings: "SettingsWrapper", + django_assert_num_queries: "DjangoAssertNumQueries", +) -> None: + # Given + expected_domain_without_site = "some-new-testing-url.com" + expected_domain_with_site = "some-testing-url.com" + settings.DEFAULT_DOMAIN = expected_domain_without_site + Site.objects.all().delete() + + # When + with django_assert_num_queries(1): + get_current_site_url() + url_without_site = get_current_site_url() + + settings.SITE_ID = Site.objects.create( + name="test_site", + domain=expected_domain_with_site, + ).id + + with django_assert_num_queries(1): + get_current_site_url() + url_with_site = get_current_site_url() + + # Then + assert url_without_site == f"https://{expected_domain_without_site}" + assert url_with_site == f"https://{expected_domain_with_site}" + + def test_get_current_site__domain_override__with_site__return_expected( settings: "SettingsWrapper", ) -> None: @@ -62,7 +91,7 @@ def test_get_current_site__domain_override__no_site__return_expected( settings: "SettingsWrapper", ) -> None: # Given - settings.SITE_ID = None + Site.objects.all().delete() expected_domain = "some-testing-url.com" settings.DOMAIN_OVERRIDE = expected_domain diff --git a/docs/docs/clients/server-side.md b/docs/docs/clients/server-side.md index 97a0de844953..f2cdb3630bcc 100644 --- a/docs/docs/clients/server-side.md +++ b/docs/docs/clients/server-side.md @@ -515,21 +515,21 @@ buttonData, _ := flags.GetFeatureValue("secret_button") ```rust -use flagsmith_flag_engine::identities::Trait; +use flagsmith::models::SDKTrait; use flagsmith_flag_engine::types::{FlagsmithValue, FlagsmithValueType}; let identifier = "delboy@trotterstraders.co.uk"; -let traits = vec![Trait { - trait_key: "car_type".to_string(), - trait_value: FlagsmithValue { +let traits = vec![SDKTrait::new( + "car_type".to_string(), + FlagsmithValue { value: "robin_reliant".to_string(), value_type: FlagsmithValueType::String, }, - }]; + )]; // The method below triggers a network request -let identity_flags = flagsmith.get_identity_flags(identifier, Some(traits)).unwrap(); +let identity_flags = flagsmith.get_identity_flags(identifier, Some(traits), None).unwrap(); let show_button = identity_flags.is_feature_enabled("secret_button").unwrap(); let button_data = identity_flags.get_feature_value_as_string("secret_button").unwrap(); diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 69bbabd26d13..c4acdfa829e3 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -229,7 +229,15 @@ const config = { ], ], - scripts: ['/js/crisp-chat.js'], + scripts: [ + '/js/crisp-chat.js', + { + src: '//js-eu1.hs-scripts.com/143451822.js', + async: true, + defer: true, + id: 'hs-script-loader', + }, + ], clientModules: [require.resolve('./plugins/crisp-chat-links.js')], }; diff --git a/docs/package-lock.json b/docs/package-lock.json index c0fe78db7a41..85a77b0a99bd 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -3533,24 +3533,6 @@ "@types/ms": "*" } }, - "node_modules/@types/eslint": { - "version": "8.44.8", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.8.tgz", - "integrity": "sha512-4K8GavROwhrYl2QXDXm0Rv9epkA8GBFu0EI+XrrnnuCl7u8CWBRusX7fXJfanhZTDWSAL24gDI/UqXyUM0Injw==", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -3847,9 +3829,9 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, "node_modules/@webassemblyjs/ast": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", - "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -3866,9 +3848,9 @@ "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", - "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==" + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.6", @@ -3886,14 +3868,14 @@ "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", - "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6" + "@webassemblyjs/wasm-gen": "1.12.1" } }, "node_modules/@webassemblyjs/ieee754": { @@ -3918,26 +3900,26 @@ "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", - "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-opt": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6", - "@webassemblyjs/wast-printer": "1.11.6" + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", - "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", "@webassemblyjs/leb128": "1.11.6", @@ -3945,22 +3927,22 @@ } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", - "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6" + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", - "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", @@ -3969,11 +3951,11 @@ } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", - "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" } }, @@ -4029,10 +4011,10 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "peerDependencies": { "acorn": "^8" } @@ -6590,9 +6572,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -15240,9 +15222,9 @@ } }, "node_modules/terser": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz", - "integrity": "sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==", + "version": "5.34.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.34.1.tgz", + "integrity": "sha512-FsJZ7iZLd/BXkz+4xrRTGJ26o/6VTjQytUk8b8OxkwcD2I+79VPJlz7qss1+zE7h8GNIScFqXcDyJ/KqBYZFVA==", "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -15257,15 +15239,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", - "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", + "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", "schema-utils": "^3.1.1", "serialize-javascript": "^6.0.1", - "terser": "^5.16.8" + "terser": "^5.26.0" }, "engines": { "node": ">= 10.13.0" @@ -16031,9 +16013,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -16071,33 +16053,32 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.89.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", - "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", - "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.0", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", + "version": "5.95.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz", + "integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==", + "dependencies": { + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", - "browserslist": "^4.14.5", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", + "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^3.2.0", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.7", - "watchpack": "^2.4.0", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "bin": { @@ -19135,24 +19116,6 @@ "@types/ms": "*" } }, - "@types/eslint": { - "version": "8.44.8", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.8.tgz", - "integrity": "sha512-4K8GavROwhrYl2QXDXm0Rv9epkA8GBFu0EI+XrrnnuCl7u8CWBRusX7fXJfanhZTDWSAL24gDI/UqXyUM0Injw==", - "requires": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "requires": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -19449,9 +19412,9 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, "@webassemblyjs/ast": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", - "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", "requires": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -19468,9 +19431,9 @@ "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" }, "@webassemblyjs/helper-buffer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", - "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==" + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" }, "@webassemblyjs/helper-numbers": { "version": "1.11.6", @@ -19488,14 +19451,14 @@ "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" }, "@webassemblyjs/helper-wasm-section": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", - "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", "requires": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6" + "@webassemblyjs/wasm-gen": "1.12.1" } }, "@webassemblyjs/ieee754": { @@ -19520,26 +19483,26 @@ "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" }, "@webassemblyjs/wasm-edit": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", - "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", "requires": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-opt": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6", - "@webassemblyjs/wast-printer": "1.11.6" + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" } }, "@webassemblyjs/wasm-gen": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", - "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", "requires": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", "@webassemblyjs/leb128": "1.11.6", @@ -19547,22 +19510,22 @@ } }, "@webassemblyjs/wasm-opt": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", - "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", "requires": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6" + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" } }, "@webassemblyjs/wasm-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", - "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", "requires": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", @@ -19571,11 +19534,11 @@ } }, "@webassemblyjs/wast-printer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", - "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", "requires": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" } }, @@ -19618,10 +19581,10 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==" }, - "acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "requires": {} }, "acorn-jsx": { @@ -21399,9 +21362,9 @@ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, "enhanced-resolve": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "requires": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -27145,9 +27108,9 @@ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" }, "terser": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz", - "integrity": "sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==", + "version": "5.34.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.34.1.tgz", + "integrity": "sha512-FsJZ7iZLd/BXkz+4xrRTGJ26o/6VTjQytUk8b8OxkwcD2I+79VPJlz7qss1+zE7h8GNIScFqXcDyJ/KqBYZFVA==", "requires": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -27163,15 +27126,15 @@ } }, "terser-webpack-plugin": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", - "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", "requires": { - "@jridgewell/trace-mapping": "^0.3.17", + "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", "schema-utils": "^3.1.1", "serialize-javascript": "^6.0.1", - "terser": "^5.16.8" + "terser": "^5.26.0" }, "dependencies": { "ajv": { @@ -27679,9 +27642,9 @@ } }, "watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "requires": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -27711,33 +27674,32 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webpack": { - "version": "5.89.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", - "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", - "requires": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.0", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", + "version": "5.95.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz", + "integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==", + "requires": { + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", - "browserslist": "^4.14.5", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", + "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^3.2.0", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.7", - "watchpack": "^2.4.0", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "dependencies": { diff --git a/frontend/api/index.js b/frontend/api/index.js index 1d13210832f6..14150068d36e 100755 --- a/frontend/api/index.js +++ b/frontend/api/index.js @@ -119,6 +119,7 @@ app.get('/config/project-overrides', (req, res) => { { name: 'albacross', value: process.env.ALBACROSS_CLIENT_ID }, { name: 'useSecureCookies', value: envToBool('USE_SECURE_COOKIES', true) }, { name: 'cookieSameSite', value: process.env.USE_SECURE_COOKIES }, + { name: 'cookieAuthEnabled', value: process.env.COOKIE_AUTH_ENABLED }, { name: 'githubAppURL', value: process.env.GITHUB_APP_URL, diff --git a/frontend/common/data/base/_data.js b/frontend/common/data/base/_data.js index 17c7e529d13a..d2ec3cb63172 100644 --- a/frontend/common/data/base/_data.js +++ b/frontend/common/data/base/_data.js @@ -9,6 +9,7 @@ const getQueryString = (params) => { module.exports = { _request(method, _url, data, headers = {}) { const options = { + credentials: Project.cookieAuthEnabled ? 'include' : undefined, headers: { 'Accept': 'application/json', ...headers, @@ -22,7 +23,7 @@ module.exports = { options.headers['Content-Type'] = 'application/json; charset=utf-8' if ( - (this.token && !isExternal) || + (this.token && !isExternal && !Project.cookieAuthEnabled) || (this.token && isExternal && method !== 'get') ) { // add auth tokens to headers of all requests diff --git a/frontend/common/service.ts b/frontend/common/service.ts index 725d930f1938..8a2f2817f547 100644 --- a/frontend/common/service.ts +++ b/frontend/common/service.ts @@ -16,6 +16,7 @@ export const baseApiOptions = (queryArgs?: Partial) => { | 'extractRehydrationInfo' > = { baseQuery: fetchBaseQuery({ + credentials: Project.cookieAuthEnabled ? 'include' : 'omit', // 'include' for cookies, 'omit' if not baseUrl: Project.api, prepareHeaders: async (headers, { endpoint, getState }) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -29,10 +30,10 @@ export const baseApiOptions = (queryArgs?: Partial) => { ) { try { const token = _data.token - if (token) { + if (token && !Project.cookieAuthEnabled) { headers.set('Authorization', `Token ${token}`) } - } catch (e) {} + } catch (e) { } } return headers diff --git a/frontend/common/services/useFeatureVersion.ts b/frontend/common/services/useFeatureVersion.ts index 041e68ec0650..6f0f87de2a34 100644 --- a/frontend/common/services/useFeatureVersion.ts +++ b/frontend/common/services/useFeatureVersion.ts @@ -1,78 +1,92 @@ import { FeatureState, - FeatureVersion, PagedResponse, + FeatureVersion, + PagedResponse, Res, Segment, -} from 'common/types/responses'; + TypedFeatureState, +} from 'common/types/responses' import { Req } from 'common/types/requests' import { service } from 'common/service' import { getStore } from 'common/store' import { getVersionFeatureState } from './useVersionFeatureState' import transformCorePaging from 'common/transformCorePaging' import Utils from 'common/utils/utils' -import { getFeatureStateDiff, getSegmentDiff } from 'components/diff/diff-utils' -import { getSegments } from 'common/services/useSegment'; -import { getFeatureStates } from 'common/services/useFeatureState'; +import { + getFeatureStateDiff, + getSegmentDiff, + getVariationDiff, +} from 'components/diff/diff-utils' +import { getSegments } from './useSegment' +import { getFeatureStates } from './useFeatureState' -const transformFeatureStates = (featureStates: FeatureState[]) => - featureStates?.map((v) => ({ - ...v, - feature_state_value: Utils.valueToFeatureState(v.feature_state_value), - id: undefined, - multivariate_feature_state_values: v.multivariate_feature_state_values?.map( - (v) => ({ - ...v, - id: undefined, - }), - ), - })) +const transformFeatureStates = (featureStates: TypedFeatureState[]) => + featureStates?.map((v) => ({ + ...v, + feature_state_value: v.feature_state_value, + id: undefined, + multivariate_feature_state_values: v.multivariate_feature_state_values?.map( + (v) => ({ + ...v, + id: undefined, + }), + ), + })) export const getFeatureStateCrud = ( - featureStates: FeatureState[], - oldFeatureStates: FeatureState[], - segments?: Segment[] | null | undefined, + featureStates: TypedFeatureState[], + oldFeatureStates: TypedFeatureState[], + segments?: Segment[] | null | undefined, ) => { - const excludeNotChanged = (featureStates: FeatureState[]) => { + const excludeNotChanged = (featureStates: TypedFeatureState[]) => { if (!oldFeatureStates) { return featureStates } if (segments?.length) { // filter out feature states that have no changes const segmentDiffs = getSegmentDiff( - featureStates, - oldFeatureStates.map((v)=>({...v,feature_state_value:Utils.featureStateToValue(v.feature_state_value)})), - segments, + featureStates, + oldFeatureStates, + segments, ) return featureStates.filter((v) => { const diff = segmentDiffs?.diffs?.find( - (diff) => v.feature_segment?.segment === diff.segment.id, + (diff) => v.feature_segment?.segment === diff.segment.id, ) return !!diff?.totalChanges }) } else { // return nothing if feature state isn't different const valueDiff = getFeatureStateDiff( - featureStates[0], - oldFeatureStates[0], + featureStates[0], + oldFeatureStates[0], ) if (!valueDiff.totalChanges) { - return [] + const variationDiff = getVariationDiff( + featureStates[0], + oldFeatureStates[0], + ) + if (!variationDiff.totalChanges) return [] } return featureStates } } - const featureStatesToCreate: Req['createFeatureVersion']['feature_states_to_create'] = - featureStates.filter((v) => !v.id && !v.toRemove) - const featureStatesToUpdate: Req['createFeatureVersion']['feature_states_to_update'] = - excludeNotChanged(featureStates.filter((v) => !!v.id && !v.toRemove)) + const featureStatesToCreate = featureStates.filter( + (v) => !v.id && !v.toRemove, + ) + const featureStatesToUpdate = excludeNotChanged( + featureStates.filter((v) => !!v.id && !v.toRemove), + ) const segment_ids_to_delete_overrides: Req['createFeatureVersion']['segment_ids_to_delete_overrides'] = - featureStates - .filter((v) => !!v.id && !!v.toRemove && !!v.feature_segment) - .map((v) => v.feature_segment!.segment) + featureStates + .filter((v) => !!v.id && !!v.toRemove && !!v.feature_segment) + .map((v) => v.feature_segment!.segment) // Step 1: Create a new feature version - const feature_states_to_create = transformFeatureStates(featureStatesToCreate) - const feature_states_to_update = transformFeatureStates(featureStatesToUpdate) + const feature_states_to_create: Req['createFeatureVersion']['feature_states_to_create'] = + transformFeatureStates(featureStatesToCreate) + const feature_states_to_update: Req['createFeatureVersion']['feature_states_to_update'] = + transformFeatureStates(featureStatesToUpdate) return { feature_states_to_create, feature_states_to_update, @@ -80,208 +94,222 @@ export const getFeatureStateCrud = ( } } export const featureVersionService = service - .enhanceEndpoints({ addTagTypes: ['FeatureVersion'] }) - .injectEndpoints({ - endpoints: (builder) => ({ - createAndSetFeatureVersion: builder.mutation< - Res['featureVersion'], - Req['createAndSetFeatureVersion'] - >({ - invalidatesTags: [{ id: 'LIST', type: 'FeatureVersion' }], - queryFn: async (query: Req['createAndSetFeatureVersion']) => { - // todo: this will be removed when we combine saving value and segment overrides - const mode = query.featureStates.find((v)=>!v.feature_segment?.segment) ? 'VALUE' : 'SEGMENT' - const oldFeatureStates: { data: PagedResponse } = (await getFeatureStates( - getStore(), - { - environment: query.environmentId, - feature: query.featureId, - }, - { - forceRefetch: true - } - )) - let segments = mode === 'VALUE'? undefined: (await getSegments(getStore(), { - include_feature_specific: true, - page_size: 1000, - projectId: query.projectId, - })).data.results + .enhanceEndpoints({ addTagTypes: ['FeatureVersion'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createAndSetFeatureVersion: builder.mutation< + Res['featureVersion'], + Req['createAndSetFeatureVersion'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'FeatureVersion' }], + queryFn: async (query: Req['createAndSetFeatureVersion']) => { + // todo: this will be removed when we combine saving value and segment overrides + const mode = query.featureStates.find( + (v) => !v.feature_segment?.segment, + ) + ? 'VALUE' + : 'SEGMENT' + const oldFeatureStates: { data: PagedResponse } = + await getFeatureStates( + getStore(), + { + environment: query.environmentId, + feature: query.featureId, + }, + { + forceRefetch: true, + }, + ) + const segments = + mode === 'VALUE' + ? undefined + : ( + await getSegments(getStore(), { + include_feature_specific: true, + page_size: 1000, + projectId: query.projectId, + }) + ).data.results + + const { + feature_states_to_create, + feature_states_to_update, + segment_ids_to_delete_overrides, + } = getFeatureStateCrud( + query.featureStates.map((v) => ({ + ...v, + feature_state_value: Utils.valueToFeatureState( + v.feature_state_value, + ), + })), + oldFeatureStates.data.results.filter((v) => { + if (mode === 'VALUE') { + return !v.feature_segment?.segment + } else { + return !!v.feature_segment?.segment + } + }), + segments, + ) + if ( + !feature_states_to_create.length && + !feature_states_to_update.length && + !segment_ids_to_delete_overrides.length + ) { + throw new Error('Feature contains no changes') + } - const { + const versionRes: { data: FeatureVersion } = + await createFeatureVersion(getStore(), { + environmentId: query.environmentId, + featureId: query.featureId, feature_states_to_create, feature_states_to_update, + live_from: query.liveFrom, + publish_immediately: !query.skipPublish, segment_ids_to_delete_overrides, - } = getFeatureStateCrud( - query.featureStates, - oldFeatureStates.data.results.filter((v)=>{ - if(mode === 'VALUE') { - return !v.feature_segment?.segment - } else { - return !!v.feature_segment?.segment - } - }), - segments, - ) - - if ( - !feature_states_to_create.length && - !feature_states_to_update.length && - !segment_ids_to_delete_overrides.length - ) { - throw new Error('Feature contains no changes') - } - - const versionRes: { data: FeatureVersion } = - await createFeatureVersion(getStore(), { - environmentId: query.environmentId, - featureId: query.featureId, - feature_states_to_create, - feature_states_to_update, - live_from: query.liveFrom, - publish_immediately: !query.skipPublish, - segment_ids_to_delete_overrides, - }) + }) - const currentFeatureStates: { data: FeatureState[] } = - await getVersionFeatureState(getStore(), { - environmentId: query.environmentId, - featureId: query.featureId, - sha: versionRes.data.uuid, - }) + const currentFeatureStates: { data: FeatureState[] } = + await getVersionFeatureState(getStore(), { + environmentId: query.environmentId, + featureId: query.featureId, + sha: versionRes.data.uuid, + }) - const res = currentFeatureStates.data + const res = currentFeatureStates.data - const ret = { - error: res.find((v) => !!v.error)?.error, - feature_states: res.map((item) => ({ - data: item, - version_sha: versionRes.data.uuid, - })), - feature_states_to_create, - feature_states_to_update, - segment_ids_to_delete_overrides, + const ret = { + error: res.find((v) => !!v.error)?.error, + feature_states: res.map((item) => ({ + data: item, version_sha: versionRes.data.uuid, - } + })), + feature_states_to_create, + feature_states_to_update, + segment_ids_to_delete_overrides, + version_sha: versionRes.data.uuid, + } - return { data: ret } as any - }, - }), - createFeatureVersion: builder.mutation< - Res['featureVersion'], - Req['createFeatureVersion'] - >({ - invalidatesTags: [{ id: 'LIST', type: 'FeatureVersion' }], - query: (query: Req['createFeatureVersion']) => ({ - body: query, - method: 'POST', - url: `environments/${query.environmentId}/features/${query.featureId}/versions/`, - }), + return { data: ret } as any + }, + }), + createFeatureVersion: builder.mutation< + Res['featureVersion'], + Req['createFeatureVersion'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'FeatureVersion' }], + query: (query: Req['createFeatureVersion']) => ({ + body: query, + method: 'POST', + url: `environments/${query.environmentId}/features/${query.featureId}/versions/`, }), - getFeatureVersion: builder.query< - Res['featureVersion'], - Req['getFeatureVersion'] - >({ - providesTags: (res) => [{ id: res?.uuid, type: 'FeatureVersion' }], - query: (query: Req['getFeatureVersion']) => ({ - url: `environment-feature-versions/${query.uuid}/`, - }), + }), + getFeatureVersion: builder.query< + Res['featureVersion'], + Req['getFeatureVersion'] + >({ + providesTags: (res) => [{ id: res?.uuid, type: 'FeatureVersion' }], + query: (query: Req['getFeatureVersion']) => ({ + url: `environment-feature-versions/${query.uuid}/`, }), - getFeatureVersions: builder.query< - Res['featureVersions'], - Req['getFeatureVersions'] - >({ - providesTags: [{ id: 'LIST', type: 'FeatureVersion' }], - query: (query) => ({ - url: `environments/${query.environmentId}/features/${ - query.featureId - }/versions/?${Utils.toParam(query)}`, - }), - transformResponse: ( - baseQueryReturnValue: Res['featureVersions'], - meta, - req, - ) => { - return transformCorePaging(req, baseQueryReturnValue) - }, + }), + getFeatureVersions: builder.query< + Res['featureVersions'], + Req['getFeatureVersions'] + >({ + providesTags: [{ id: 'LIST', type: 'FeatureVersion' }], + query: (query) => ({ + url: `environments/${query.environmentId}/features/${ + query.featureId + }/versions/?${Utils.toParam(query)}`, }), - publishFeatureVersion: builder.mutation< - Res['featureVersion'], - Req['publishFeatureVersion'] - >({ - invalidatesTags: [{ id: 'LIST', type: 'FeatureVersion' }], - query: (query: Req['publishFeatureVersion']) => ({ - body: query, - method: 'POST', - url: `environments/${query.environmentId}/features/${query.featureId}/versions/${query.sha}/publish/`, - }), + transformResponse: ( + baseQueryReturnValue: Res['featureVersions'], + meta, + req, + ) => { + return transformCorePaging(req, baseQueryReturnValue) + }, + }), + publishFeatureVersion: builder.mutation< + Res['featureVersion'], + Req['publishFeatureVersion'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'FeatureVersion' }], + query: (query: Req['publishFeatureVersion']) => ({ + body: query, + method: 'POST', + url: `environments/${query.environmentId}/features/${query.featureId}/versions/${query.sha}/publish/`, }), - // END OF ENDPOINTS }), - }) + // END OF ENDPOINTS + }), + }) export async function createFeatureVersion( - store: any, - data: Req['createFeatureVersion'], - options?: Parameters< - typeof featureVersionService.endpoints.createFeatureVersion.initiate - >[1], + store: any, + data: Req['createFeatureVersion'], + options?: Parameters< + typeof featureVersionService.endpoints.createFeatureVersion.initiate + >[1], ) { return store.dispatch( - featureVersionService.endpoints.createFeatureVersion.initiate( - data, - options, - ), + featureVersionService.endpoints.createFeatureVersion.initiate( + data, + options, + ), ) } export async function publishFeatureVersion( - store: any, - data: Req['publishFeatureVersion'], - options?: Parameters< - typeof featureVersionService.endpoints.publishFeatureVersion.initiate - >[1], + store: any, + data: Req['publishFeatureVersion'], + options?: Parameters< + typeof featureVersionService.endpoints.publishFeatureVersion.initiate + >[1], ) { return store.dispatch( - featureVersionService.endpoints.publishFeatureVersion.initiate( - data, - options, - ), + featureVersionService.endpoints.publishFeatureVersion.initiate( + data, + options, + ), ) } export async function createAndSetFeatureVersion( - store: any, - data: Req['createAndSetFeatureVersion'], - options?: Parameters< - typeof featureVersionService.endpoints.createAndSetFeatureVersion.initiate - >[1], + store: any, + data: Req['createAndSetFeatureVersion'], + options?: Parameters< + typeof featureVersionService.endpoints.createAndSetFeatureVersion.initiate + >[1], ) { return store.dispatch( - featureVersionService.endpoints.createAndSetFeatureVersion.initiate( - data, - options, - ), + featureVersionService.endpoints.createAndSetFeatureVersion.initiate( + data, + options, + ), ) } export async function getFeatureVersions( - store: any, - data: Req['getFeatureVersions'], - options?: Parameters< - typeof featureVersionService.endpoints.getFeatureVersions.initiate - >[1], + store: any, + data: Req['getFeatureVersions'], + options?: Parameters< + typeof featureVersionService.endpoints.getFeatureVersions.initiate + >[1], ) { return store.dispatch( - featureVersionService.endpoints.getFeatureVersions.initiate(data, options), + featureVersionService.endpoints.getFeatureVersions.initiate(data, options), ) } export async function getFeatureVersion( - store: any, - data: Req['getFeatureVersion'], - options?: Parameters< - typeof featureVersionService.endpoints.getFeatureVersion.initiate - >[1], + store: any, + data: Req['getFeatureVersion'], + options?: Parameters< + typeof featureVersionService.endpoints.getFeatureVersion.initiate + >[1], ) { return store.dispatch( - featureVersionService.endpoints.getFeatureVersion.initiate(data, options), + featureVersionService.endpoints.getFeatureVersion.initiate(data, options), ) } // END OF FUNCTION_EXPORTS diff --git a/frontend/common/stores/account-store.js b/frontend/common/stores/account-store.js index bd847484fcd9..8575cfb62a4a 100644 --- a/frontend/common/stores/account-store.js +++ b/frontend/common/stores/account-store.js @@ -6,6 +6,7 @@ const data = require('../data/base/_data') import Constants from 'common/constants' import dataRelay from 'data-relay' import { sortBy } from 'lodash' +import Project from 'common/project' const controller = { acceptInvite: (id) => { @@ -186,7 +187,7 @@ const controller = { return } - data.setToken(res.key) + data.setToken(Project.cookieAuthEnabled ? 'true' : res.key) return controller.onLogin() }) .catch((e) => API.ajaxHandler(store, e)) @@ -218,14 +219,14 @@ const controller = { return } - data.setToken(res.key) + data.setToken(Project.cookieAuthEnabled ? 'true' : res.key) return controller.onLogin() }) .catch((e) => API.ajaxHandler(store, e)) }, onLogin: (skipCaching) => { if (!skipCaching) { - API.setCookie('t', data.token) + API.setCookie('t', Project.cookieAuthEnabled ? 'true' : data.token) } return controller.getOrganisations() }, @@ -241,15 +242,15 @@ const controller = { .post(`${Project.api}auth/users/`, { email, first_name, + invite_hash: API.getInvite() || undefined, last_name, marketing_consent_given, password, referrer: API.getReferrer() || '', sign_up_type: API.getInviteType(), - invite_hash: API.getInvite() || undefined, }) .then((res) => { - data.setToken(res.key) + data.setToken(Project.cookieAuthEnabled ? 'true' : res.key) API.trackEvent(Constants.events.REGISTER) if (API.getReferrer()) { API.trackEvent( @@ -291,7 +292,7 @@ const controller = { store.loading() store.user = {} - data.setToken(token) + data.setToken(Project.cookieAuthEnabled ? 'true' : token) return controller.onLogin() }, @@ -328,12 +329,20 @@ const controller = { } else if (!user) { store.ephemeral_token = null AsyncStorage.clear() - API.setCookie('t', '') - data.setToken(null) - API.reset().finally(() => { - store.model = user - store.organisation = null - store.trigger('logout') + if (!data.token) { + return + } + ;(Project.cookieAuthEnabled + ? data.post(`${Project.api}auth/logout/`, {}) + : Promise.resolve() + ).finally(() => { + API.setCookie('t', '') + data.setToken(null) + API.reset().finally(() => { + store.model = user + store.organisation = null + store.trigger('logout') + }) }) } }, @@ -348,7 +357,7 @@ const controller = { .then((res) => { store.model = null API.trackEvent(Constants.events.LOGIN) - data.setToken(res.key) + data.setToken(Project.cookieAuthEnabled ? 'true' : res.key) store.ephemeral_token = null controller.onLogin() }) diff --git a/frontend/common/stores/feature-list-store.ts b/frontend/common/stores/feature-list-store.ts index c7379c316f9b..be445d281df7 100644 --- a/frontend/common/stores/feature-list-store.ts +++ b/frontend/common/stores/feature-list-store.ts @@ -53,6 +53,8 @@ const convertSegmentOverrideToFeatureState = ( feature_state_value: override.value, id: override.id, live_from: changeRequest?.live_from, + multivariate_feature_state_values: + override.multivariate_options, toRemove: override.toRemove, } as Partial } @@ -113,7 +115,7 @@ const controller = { }) .then(() => Promise.all([ - data.get(`${Project.api}projects/${projectId}/features/`), + data.get(`${Project.api}projects/${projectId}/features?environment=${ProjectStore.getEnvironmentIdFromKey(environmentId)}`), ]).then(([features]) => { const environmentFeatures = features.results.map((v) => ({ ...v.environment_feature_state, @@ -526,16 +528,9 @@ const controller = { oldFeatureStates, segments, ) - const convertFeatureStateToValue = (v: any) => ({ - ...v, - feature_state_value: Utils.featureStateToValue(v.feature_state_value), - }) - feature_states_to_create = version.feature_states_to_create?.map( - convertFeatureStateToValue, - ) - feature_states_to_update = version.feature_states_to_update?.map( - convertFeatureStateToValue, - ) + + feature_states_to_create = version.feature_states_to_create + feature_states_to_update = version.feature_states_to_update segment_ids_to_delete_overrides = version.segment_ids_to_delete_overrides @@ -865,12 +860,12 @@ const controller = { ? page : `${Project.api}projects/${projectId}/features/?page=${ page || 1 - }&page_size=${pageSize || PAGE_SIZE}${filterUrl}` + }&environment=${environment}&page_size=${pageSize || PAGE_SIZE}${filterUrl}` if (store.search) { featuresEndpoint += `&search=${store.search}` } if (store.sort) { - featuresEndpoint += `&environment=${environment}&sort_field=${ + featuresEndpoint += `&sort_field=${ store.sort.sortBy }&sort_direction=${store.sort.sortOrder.toUpperCase()}` } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index b26f76c33d97..1ea21e07ec8a 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -390,6 +390,10 @@ export type FeatureState = { toRemove?: boolean } +export type TypedFeatureState = Omit & { + feature_state_value: FeatureStateValue +} + export type ProjectFlag = { created_date: string default_enabled: boolean @@ -502,7 +506,7 @@ export type FeatureConflict = { published_at: string is_environment_default: boolean } -export type FeatureStateWithConflict = FeatureState & { +export type FeatureStateWithConflict = TypedFeatureState & { conflict?: FeatureConflict } export type ChangeRequest = { diff --git a/frontend/e2e/helpers.cafe.ts b/frontend/e2e/helpers.cafe.ts index eb92aac91684..fb7465b432a6 100644 --- a/frontend/e2e/helpers.cafe.ts +++ b/frontend/e2e/helpers.cafe.ts @@ -362,7 +362,6 @@ export const editRemoteConfig = async ( } await Promise.all( mvs.map(async (v, i) => { - await setText(byId(`featureVariationValue${i}`), v.value) await setText(byId(`featureVariationWeight${v.value}`), `${v.weight}`) }), ) diff --git a/frontend/env/project_prod.js b/frontend/env/project_prod.js index 24d0d388e496..752cba97fa40 100644 --- a/frontend/env/project_prod.js +++ b/frontend/env/project_prod.js @@ -20,6 +20,8 @@ module.exports = global.Project = { flagsmithClientAPI: 'https://api.flagsmith.com/api/v1/', flagsmithClientEdgeAPI: 'https://edge.api.flagsmith.com/api/v1/', + + hubspot: '//js-eu1.hs-scripts.com/143451822.js', // This is used for Sentry tracking maintenance: false, plans: { diff --git a/frontend/web/components/WarningMessage.tsx b/frontend/web/components/WarningMessage.tsx index af5e560a8892..aa5c4667d02a 100644 --- a/frontend/web/components/WarningMessage.tsx +++ b/frontend/web/components/WarningMessage.tsx @@ -14,6 +14,9 @@ const WarningMessage: FC = (props) => { const warningMessageClassName = `alert alert-warning ${ warningMessageClass || 'flex-1 align-items-center' }` + if(!props.warningMessage) { + return null + } return (
= ({ projectId, tabTheme, }) => { + const {data:projectFlag} = useGetProjectFlagQuery({project:projectId, id: featureId}) + const oldEnv = oldState?.find((v) => !v.feature_segment) const newEnv = newState?.find((v) => !v.feature_segment) const { data: feature } = useGetProjectFlagQuery({ @@ -65,7 +67,7 @@ const DiffFeature: FC = ({ const segmentDiffs = disableSegments ? { diffs: [], totalChanges: 0 } : getSegmentDiff(oldState, newState, segments?.results, conflicts) - const variationDiffs = getVariationDiff(oldEnv, newEnv, feature) + const variationDiffs = getVariationDiff(oldEnv, newEnv) const totalSegmentChanges = segmentDiffs?.totalChanges const totalVariationChanges = variationDiffs?.totalChanges useEffect(() => { @@ -105,11 +107,6 @@ const DiffFeature: FC = ({
} > - {!totalChanges && ( -
- No Changes Found -
- )} {!!valueConflict && (
= ({
} > - +
)} {!!segmentDiffs?.diffs.length && ( diff --git a/frontend/web/components/diff/DiffSegments.tsx b/frontend/web/components/diff/DiffSegments.tsx index 98c56006651f..6f710cf90927 100644 --- a/frontend/web/components/diff/DiffSegments.tsx +++ b/frontend/web/components/diff/DiffSegments.tsx @@ -9,6 +9,7 @@ import Utils from 'common/utils/utils' import Icon from 'components/Icon' import Tooltip from 'components/Tooltip' import { Link } from 'react-router-dom' +import DiffVariations from './DiffVariations' type DiffSegment = { diff: TDiffSegment @@ -19,49 +20,56 @@ type DiffSegment = { const widths = [200, 80, 105] const DiffSegment: FC = ({ diff, environmentId, projectId }) => { return ( -
-
-
- -
- {!!diff.conflict && } - {diff.segment?.name} -
- {!!diff.conflict && ( - - View Change Request - - )} - - } - > - {diff.conflict - ? 'A change request was published since the creation of this one that also modified the value for this segment.' - : null} -
+
+
+
+
+ +
+ {!!diff.conflict && } + {diff.segment?.name} +
+ {!!diff.conflict && ( + + View Change Request + + )} + + } + > + {diff.conflict + ? 'A change request was published since the creation of this one that also modified the value for this segment.' + : null} +
+
+
+
+ +
+
+ +
+
+
-
- -
-
- -
-
- +
+ {!!diff.variationDiff?.diffs && ( + + )}
) diff --git a/frontend/web/components/diff/DiffVariations.tsx b/frontend/web/components/diff/DiffVariations.tsx index 89d944431e8e..f6147f4f96a5 100644 --- a/frontend/web/components/diff/DiffVariations.tsx +++ b/frontend/web/components/diff/DiffVariations.tsx @@ -2,13 +2,16 @@ import { TDiffVariation } from './diff-utils' import React, { FC } from 'react' import DiffString from './DiffString' import { DiffMethod } from 'react-diff-viewer-continued' +import { ProjectFlag } from 'common/types/responses'; +import Utils from 'common/utils/utils'; const widths = [120] type DiffVariationsType = { diffs: TDiffVariation[] | undefined + projectFlag: ProjectFlag | undefined } -const DiffVariations: FC = ({ diffs }) => { +const DiffVariations: FC = ({ diffs, projectFlag }) => { const tableHeader = (
Value
@@ -21,28 +24,32 @@ const DiffVariations: FC = ({ diffs }) => { return ( <> {tableHeader} - {diffs?.map((diff, i) => ( - -
- -
-
- -
-
- ))} + {diffs?.map((diff, i) => { + const variation = projectFlag?.multivariate_options?.find((v)=>v.id === diff.variationOption) + const stringValue = variation?Utils.featureStateToValue(variation): '' + return ( + +
+ +
+
+ +
+
+ ) + })} ) } diff --git a/frontend/web/components/diff/diff-utils.ts b/frontend/web/components/diff/diff-utils.ts index db368c6dfd7d..61ca6c0384fe 100644 --- a/frontend/web/components/diff/diff-utils.ts +++ b/frontend/web/components/diff/diff-utils.ts @@ -6,7 +6,7 @@ import { Segment, } from 'common/types/responses' import Utils from 'common/utils/utils' -import { sortBy } from 'lodash' +import { sortBy, uniq, uniqBy } from 'lodash' export function getFeatureStateDiff( oldFeatureState: FeatureState | undefined, newFeatureState: FeatureStateWithConflict | undefined, @@ -44,14 +44,17 @@ export type TDiffSegment = { oldValue: string conflict?: FeatureConflict totalChanges: number + variationDiff?: { + diffs: TDiffVariation[] + totalChanges: number + } created: boolean deleted: boolean } export type TDiffVariation = { hasChanged: boolean - newValue: string + variationOption: number newWeight: number - oldValue: string oldWeight: number } export type TDiffVariations = { @@ -97,6 +100,8 @@ export const getSegmentDiff = ( } } + const variationDiff = getVariationDiff(oldFeatureState, newFeatureState) + const oldEnabled = !!oldFeatureState?.enabled const oldPriority = oldFeatureState?.feature_segment ? oldFeatureState.feature_segment.priority + 1 @@ -126,6 +131,7 @@ export const getSegmentDiff = ( const segmentChanges = (enabledChanged ? 1 : 0) + (valueChanged ? 1 : 0) + + variationDiff.totalChanges + (priorityChanged ? 1 : 0) if (segmentChanges) { totalChanges += 1 @@ -143,6 +149,7 @@ export const getSegmentDiff = ( oldValue, segment, totalChanges: segmentChanges, + variationDiff, } as TDiffSegment }) return { @@ -155,31 +162,36 @@ export const getSegmentDiff = ( export const getVariationDiff = ( oldFeatureState: FeatureState | undefined, newFeatureState: FeatureState | undefined, - feature: ProjectFlag | undefined, ) => { let totalChanges = 0 - const diffs = feature?.multivariate_options?.map((variationOption) => { + const variationOptions = uniqBy( + oldFeatureState?.multivariate_feature_state_values || + [].concat(newFeatureState?.multivariate_feature_state_values || []), + (v) => v.multivariate_feature_option, + ) + const diffs = variationOptions.map((variationOption) => { const oldMV = oldFeatureState?.multivariate_feature_state_values?.find( - (v) => v.multivariate_feature_option === variationOption.id, + (v) => + v.multivariate_feature_option === + variationOption.multivariate_feature_option, ) const newMV = newFeatureState?.multivariate_feature_state_values?.find( - (v) => v.multivariate_feature_option === variationOption.id, + (v) => + v.multivariate_feature_option === + variationOption.multivariate_feature_option, ) - const oldValue = variationOption.string_value - const newValue = variationOption.string_value // todo: This would eventually be based on the old and new feature versions const oldWeight = oldMV?.percentage_allocation const newWeight = newMV?.percentage_allocation - const hasChanged = oldWeight !== newWeight || oldValue !== newValue + const hasChanged = oldWeight !== newWeight if (hasChanged) { totalChanges += 1 } return { hasChanged, - newValue, newWeight, - oldValue, oldWeight, + variationOption: variationOption.multivariate_feature_option, } as TDiffVariation }) diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/CreateFlag.js index 6d968ea6b7b6..99dd33991c6f 100644 --- a/frontend/web/components/modals/CreateFlag.js +++ b/frontend/web/components/modals/CreateFlag.js @@ -45,6 +45,7 @@ import ExternalResourcesLinkTab from 'components/ExternalResourcesLinkTab' import { saveFeatureWithValidation } from 'components/saveFeatureWithValidation' import PlanBasedBanner from 'components/PlanBasedAccess' import FeatureHistory from 'components/FeatureHistory' +import WarningMessage from 'components/WarningMessage'; const CreateFlag = class extends Component { static displayName = 'CreateFlag' @@ -402,7 +403,17 @@ const CreateFlag = class extends Component { }, }) } - +parseError = (error)=>{ + const { projectFlag } = this.props + let featureError = error?.message || error?.name?.[0] || error + let featureWarning = '' + //Treat multivariate no changes as warnings + if(featureError?.includes?.("no changes") && projectFlag?.multivariate_options?.length) { + featureWarning = `Your feature contains no changes to its value, enabled state or environment weights. If you have adjusted any variation values this will have been saved for all environments.` + featureError = '' + } + return {featureError, featureWarning} +} drawChart = (data) => { return data?.length ? ( @@ -558,6 +569,7 @@ const CreateFlag = class extends Component { const metadataEnable = Utils.getPlansPermission('METADATA') && Utils.getFlagsmithHasFeature('enable_metadata') + try { if (!isEdit && name && regex) { regexValid = name.match(new RegExp(regex)) @@ -711,130 +723,134 @@ const CreateFlag = class extends Component { ) - const Value = (error, projectAdmin, createFeature, hideValue) => ( - <> - {!!isEdit && ( - - )} - {!isEdit && ( - - (this.input = e)} - data-test='featureID' - inputProps={{ - className: 'full-width', - maxLength: FEATURE_ID_MAXLENGTH, - name: 'featureID', - readOnly: isEdit, - }} - value={name} - onChange={(e) => { - const newName = Utils.safeParseEventValue(e).replace(/ /g, '_') - this.setState({ - name: caseSensitive ? newName.toLowerCase() : newName, - }) - }} - isValid={!!name && regexValid} - type='text' - title={ - <> - - - {isEdit ? 'ID / Name' : 'ID / Name*'} - - -
- } - > - The ID that will be used by SDKs to retrieve the feature - value and enabled state. This cannot be edited once the - feature has been created. - - {!!regex && !isEdit && ( -
- {' '} - - {' '} - This must conform to the regular expression{' '} -
{regex}
-
-
- )} - - } - placeholder='E.g. header_size' - /> - - )} - - {identity && description && ( - - - this.setState({ description: Utils.safeParseEventValue(e) }) - } - type='text' - title={identity ? 'Description' : 'Description (optional)'} - placeholder='No description' - /> - - )} - {!hideValue && ( -
- { - this.setState({ identityVariations, valueChanged: true }) - }} - environmentFlag={this.props.environmentFlag} - projectFlag={projectFlag} - onValueChange={(e) => { - const initial_value = Utils.getTypedValue( - Utils.safeParseEventValue(e), - ) - this.setState({ initial_value, valueChanged: true }) - }} - onCheckedChange={(default_enabled) => - this.setState({ default_enabled }) - } - /> -
- )} - {!isEdit && - !identity && - Settings(projectAdmin, createFeature, featureContentType)} - - ) + const Value = (error, projectAdmin, createFeature, hideValue) => { + const {featureError, featureWarning}= this.parseError(error) + return ( + <> + {!!isEdit && ( + + )} + {!isEdit && ( + + (this.input = e)} + data-test='featureID' + inputProps={{ + className: 'full-width', + maxLength: FEATURE_ID_MAXLENGTH, + name: 'featureID', + readOnly: isEdit, + }} + value={name} + onChange={(e) => { + const newName = Utils.safeParseEventValue(e).replace(/ /g, '_') + this.setState({ + name: caseSensitive ? newName.toLowerCase() : newName, + }) + }} + isValid={!!name && regexValid} + type='text' + title={ + <> + + + {isEdit ? 'ID / Name' : 'ID / Name*'} + + + + } + > + The ID that will be used by SDKs to retrieve the feature + value and enabled state. This cannot be edited once the + feature has been created. + + {!!regex && !isEdit && ( +
+ {' '} + + {' '} + This must conform to the regular expression{' '} +
{regex}
+
+
+ )} + + } + placeholder='E.g. header_size' + /> +
+ )} + + + {identity && description && ( + + + this.setState({ description: Utils.safeParseEventValue(e) }) + } + type='text' + title={identity ? 'Description' : 'Description (optional)'} + placeholder='No description' + /> + + )} + {!hideValue && ( +
+ { + this.setState({ identityVariations, valueChanged: true }) + }} + environmentFlag={this.props.environmentFlag} + projectFlag={projectFlag} + onValueChange={(e) => { + const initial_value = Utils.getTypedValue( + Utils.safeParseEventValue(e), + ) + this.setState({ initial_value, valueChanged: true }) + }} + onCheckedChange={(default_enabled) => + this.setState({ default_enabled }) + } + /> +
+ )} + {!isEdit && + !identity && + Settings(projectAdmin, createFeature, featureContentType)} + + ) + } return ( {({ project }) => ( @@ -1025,6 +1041,10 @@ const CreateFlag = class extends Component { project.total_features, project.max_features_allowed, ) + const {featureError, featureWarning}= this.parseError(error) + + + return ( +