diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt index f5584510f..2029b16d1 100644 --- a/backend/requirements/base.txt +++ b/backend/requirements/base.txt @@ -7,7 +7,7 @@ cookiecutter==2.3.0 coverage==7.6.1 crispy-bootstrap5==0.7 cryptography==44.0.3 -django==4.2.15 +django==4.2.16 django-axes==6.4.0 django-cachalot==2.6.1 django-celery-beat==2.5.0 @@ -28,6 +28,7 @@ django-smtp-ssl==1.0 django-storages==1.14 django-tenants==3.6.1 djangorestframework==3.15.2 +fido2==2.0.0 flake8==6.1.0 flower==2.0.1 GitPython==3.1.43 diff --git a/backend/src/zango/api/app_auth/config/__init__.py b/backend/src/zango/api/app_auth/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/zango/api/app_auth/config/v1/__init__.py b/backend/src/zango/api/app_auth/config/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/zango/api/app_auth/config/v1/urls.py b/backend/src/zango/api/app_auth/config/v1/urls.py new file mode 100644 index 000000000..ba949990f --- /dev/null +++ b/backend/src/zango/api/app_auth/config/v1/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import AppAuthConfigViewAPIV1 + + +urlpatterns = [ + path("", AppAuthConfigViewAPIV1.as_view(), name="app-auth-config"), +] diff --git a/backend/src/zango/api/app_auth/config/v1/views.py b/backend/src/zango/api/app_auth/config/v1/views.py new file mode 100644 index 000000000..50bfc79d0 --- /dev/null +++ b/backend/src/zango/api/app_auth/config/v1/views.py @@ -0,0 +1,16 @@ +from rest_framework.views import APIView + +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import ensure_csrf_cookie + +from zango.core.api import get_api_response +from zango.core.utils import get_auth_priority + + +@method_decorator(ensure_csrf_cookie, name="dispatch") +class AppAuthConfigViewAPIV1(APIView): + def get(self, request): + response = {"auth_config": get_auth_priority(request=request)} + status = 200 + success = True + return get_api_response(success, response, status) diff --git a/backend/src/zango/api/app_auth/flows/__init__.py b/backend/src/zango/api/app_auth/flows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/zango/api/app_auth/flows/v1/__init__.py b/backend/src/zango/api/app_auth/flows/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/zango/api/app_auth/flows/v1/forms.py b/backend/src/zango/api/app_auth/flows/v1/forms.py new file mode 100644 index 000000000..d0806dfaf --- /dev/null +++ b/backend/src/zango/api/app_auth/flows/v1/forms.py @@ -0,0 +1,35 @@ +from allauth.headless.internal.restkit import inputs + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from zango.apps.appauth.models import OldPasswords + + +class BaseSetPasswordForm(forms.Form): + new_password = forms.CharField( + label=_("New password"), + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + "placeholder": "Enter your new password", + "autocomplete": "new-password", + } + ), + strip=False, + ) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user") + super().__init__(*args, **kwargs) + + def save(self): + self.user.set_password(self.cleaned_data["new_password"]) + self.user.save() + obj = OldPasswords.objects.create(user=self.user) + obj.setPasswords(self.user.password) + obj.save() + + +class PasswordSetForm(BaseSetPasswordForm, inputs.Input): + pass diff --git a/backend/src/zango/api/app_auth/flows/v1/login.py b/backend/src/zango/api/app_auth/flows/v1/login.py new file mode 100644 index 000000000..2ae759775 --- /dev/null +++ b/backend/src/zango/api/app_auth/flows/v1/login.py @@ -0,0 +1,60 @@ +import json + +from allauth.account.stages import LoginStageController, RoleSelectionStage +from allauth.headless.account.views import LoginView +from allauth.headless.base import response +from allauth.headless.base.views import APIView + +from zango.apps.appauth.models import UserRoleModel +from zango.core.api import get_api_response + + +class AppLoginViewAPIV1(LoginView): + def post(self, request, *args, **kwargs): + resp = super().post(request, *args, **kwargs) + return get_api_response( + success=True, + response_content=json.loads(resp.content.decode("utf-8")), + status=resp.status_code, + ) + + +class UserRoleViewAPIV1(APIView): + stage_class = RoleSelectionStage + + def handle(self, request, *args, **kwargs): + self.stage = LoginStageController.enter(request, self.stage_class.key) + if not self.stage: + return response.UnauthorizedResponse(request) + return super().handle(request, *args, **kwargs) + + def respond_stage_error(self): + return response.UnauthorizedResponse(self.request) + + def respond_next_stage(self): + self.stage.exit() + return response.AuthenticationResponse(self.request) + + def post(self, request, *args, **kwargs): + role = request.GET.get("role") + if role is None: + return get_api_response( + success=False, + response_content={"message": "user role not specified"}, + status=400, + ) + try: + UserRoleModel.objects.get(id=role) + except UserRoleModel.DoesNotExist: + return get_api_response( + success=False, + response_content={"message": "specified user role does not exist"}, + status=400, + ) + request.session["role_id"] = role + response = self.respond_next_stage() + return get_api_response( + success=True, + response_content=json.loads(response.content.decode("utf-8")), + status=response.status_code, + ) diff --git a/backend/src/zango/api/app_auth/flows/v1/login_with_code.py b/backend/src/zango/api/app_auth/flows/v1/login_with_code.py new file mode 100644 index 000000000..655bca276 --- /dev/null +++ b/backend/src/zango/api/app_auth/flows/v1/login_with_code.py @@ -0,0 +1,25 @@ +import json + +from allauth.headless.account.views import ConfirmLoginCodeView, RequestLoginCodeView + +from zango.core.api import get_api_response + + +class RequestLoginCodeViewAPIV1(RequestLoginCodeView): + def post(self, request, *args, **kwargs): + resp = super().post(request, *args, **kwargs) + return get_api_response( + success=True, + response_content=json.loads(resp.content.decode("utf-8")), + status=resp.status_code, + ) + + +class ConfirmLoginCodeViewAPIV1(ConfirmLoginCodeView): + def post(self, request, *args, **kwargs): + resp = super().post(request, *args, **kwargs) + return get_api_response( + success=True, + response_content=json.loads(resp.content.decode("utf-8")), + status=resp.status_code, + ) diff --git a/backend/src/zango/api/app_auth/flows/v1/logout.py b/backend/src/zango/api/app_auth/flows/v1/logout.py new file mode 100644 index 000000000..3af049929 --- /dev/null +++ b/backend/src/zango/api/app_auth/flows/v1/logout.py @@ -0,0 +1,15 @@ +import json + +from allauth.headless.account.views import SessionView + +from zango.core.api import get_api_response + + +class AppLogoutViewAPIV1(SessionView): + def delete(self, request, *args, **kwargs): + resp = super().delete(request, *args, **kwargs) + return get_api_response( + success=True, + response_content=json.loads(resp.content.decode("utf-8")), + status=resp.status_code, + ) diff --git a/backend/src/zango/api/app_auth/flows/v1/mfa.py b/backend/src/zango/api/app_auth/flows/v1/mfa.py new file mode 100644 index 000000000..76bdd3b1b --- /dev/null +++ b/backend/src/zango/api/app_auth/flows/v1/mfa.py @@ -0,0 +1,127 @@ +import json + +from allauth.headless.mfa.views import AuthenticateView +from rest_framework.views import APIView + +from zango.apps.appauth.tasks import send_otp +from zango.core.api import get_api_response +from zango.core.utils import get_auth_priority, mask_email, mask_phone_number + + +class GetMFACodeViewAPIV1(APIView): + def get_user(self, username): + from django.db.models import Q + + from zango.apps.appauth.models import AppUserModel + + try: + return AppUserModel.objects.get(Q(email=username) | Q(mobile=username)) + except AppUserModel.DoesNotExist: + return None + + def get(self, request, *args, **kwargs): + policy = get_auth_priority(policy="two_factor_auth", request=request) + if not policy.get("required"): + return get_api_response( + success=False, + response_content={"message": "MFA not required"}, + status=400, + ) + else: + allowed_methods = policy.get("allowed_methods", []) + if len(allowed_methods) == 0: + return get_api_response( + success=False, + response_content={"message": "No MFA methods configured"}, + status=400, + ) + + if len(request.session.get("account_authentication_methods", [])) > 0: + latest_auth_method = request.session["account_authentication_methods"][ + 0 + ] + preferred_method = None + + request_data = { + "path": request.path, + "params": request.GET, + } + + user = None + if latest_auth_method.get("email"): + user = self.get_user(latest_auth_method.get("email")) + if user is None: + return get_api_response( + success=False, + response_content={"message": "User not found"}, + status=400, + ) + preferred_method = "sms" + if preferred_method not in allowed_methods: + return get_api_response( + success=False, + response_content={"message": "SMS MFA method not allowed"}, + status=400, + ) + send_otp.delay( + method=preferred_method, + otp_type="two_factor_auth", + user_id=user.id, + tenant_id=request.tenant.id, + request_data=request_data, + user_role_id=request.session.get("role_id"), + message="Your 2FA code is", + ) + else: + user = self.get_user(latest_auth_method.get("phone")) + if user is None: + return get_api_response( + success=False, + response_content={"message": "User not found"}, + status=400, + ) + preferred_method = "email" + if preferred_method not in allowed_methods: + return get_api_response( + success=False, + response_content={ + "message": "Email MFA method not allowed" + }, + status=400, + ) + send_otp.delay( + method=preferred_method, + otp_type="two_factor_auth", + user_id=user.id, + tenant_id=request.tenant.id, + request_data=request_data, + user_role_id=request.session.get("role_id"), + message="Your 2FA code is", + subject="2FA Verification Code", + ) + return get_api_response( + success=True, + response_content={ + "message": f"Verification code sent to {preferred_method}", + "masked_destination": mask_email(user.email) + if preferred_method == "email" + else mask_phone_number(str(user.mobile)), + }, + status=200, + ) + else: + return get_api_response( + success=False, + response_content={"message": "User not authenticated"}, + status=400, + ) + + +class MFAVerifyViewAPIV1(AuthenticateView): + def post(self, request, *args, **kwargs): + resp = super().post(request, *args, **kwargs) + return get_api_response( + success=True, + response_content=json.loads(resp.content.decode("utf-8")), + status=resp.status_code, + ) diff --git a/backend/src/zango/api/app_auth/flows/v1/password.py b/backend/src/zango/api/app_auth/flows/v1/password.py new file mode 100644 index 000000000..a0e440d28 --- /dev/null +++ b/backend/src/zango/api/app_auth/flows/v1/password.py @@ -0,0 +1,127 @@ +import json + +from allauth.account.stages import LoginStageController, SetPasswordStage +from allauth.headless.account.views import ChangePasswordView, RequestPasswordResetView +from allauth.headless.base import response +from allauth.headless.base.views import APIView + +from django.contrib.auth import authenticate +from django.core.exceptions import ValidationError + +from zango.api.app_auth.profile.v1.utils import PasswordValidationMixin +from zango.apps.appauth.models import OldPasswords +from zango.core.api import ( + ZangoSessionAppAPIView, + get_api_response, +) + +from .forms import PasswordSetForm + + +class PasswordChangeViewAPIV1(ChangePasswordView, PasswordValidationMixin): + def clean_password(self, request, email, password): + """ + Validates that the current password is correct + """ + try: + user = authenticate(request=request, username=email, password=password) + except Exception: + import traceback + + traceback.print_exc() + raise ValidationError( + "The current password you have entered is wrong. Please try again!" + ) + + def clean_password2(self, user, current_password, new_password): + """method to validate password""" + password2 = new_password + validation = self.run_all_validations( + user, new_password, password2, current_password + ) + if not validation.get("validation"): + raise ValidationError(validation.get("msg")) + return True + + def put(self, request, *args, **kwargs): + success = False + + if request.tenant.auth_config.get("password_policy", {}).get( + "allow_change", True + ): + try: + self.clean_password( + request, + request.user.email, + self.input.cleaned_data["current_password"], + ) + self.clean_password2( + request.user, + self.input.cleaned_data["current_password"], + self.input.cleaned_data["new_password"], + ) + + user = request.user + + resp = super().post(request, *args, **kwargs) + + if resp.status_code != 401: + return get_api_response( + success=False, + response_content=resp.content.decode("utf-8"), + status=resp.status_code, + ) + + user.refresh_from_db() + obj = OldPasswords.objects.create(user=user) + obj.setPasswords(user.password) + obj.save() + success = True + status = 200 + return get_api_response(success, resp.content.decode("utf-8"), status) + except ValidationError as e: + response = {"message": e.message} + if success: + status = 200 + else: + status = 400 + return get_api_response(success, response, status) + else: + response = {"message": "Password change is disabled"} + success = False + status = 400 + return get_api_response(success, response, status) + + +class SetPasswordViewAPIV1(APIView): + input_class = PasswordSetForm + stage_class = SetPasswordStage + + def handle(self, request, *args, **kwargs): + self.stage = LoginStageController.enter(request, self.stage_class.key) + if not self.stage: + return response.UnauthorizedResponse(request) + return super().handle(request, *args, **kwargs) + + def respond_stage_error(self): + return response.UnauthorizedResponse(self.request) + + def respond_next_stage(self): + self.stage.exit() + return response.AuthenticationResponse(self.request) + + def get_input_kwargs(self): + return {"user": self.stage.login.user} + + def post(self, request, *args, **kwargs): + self.input.save() + response = self.respond_next_stage() + return get_api_response( + success=True, + response_content=json.loads(response.content.decode("utf-8")), + status=response.status_code, + ) + + +class RequestResetPasswordViewAPIV1(ZangoSessionAppAPIView, RequestPasswordResetView): + pass diff --git a/backend/src/zango/api/app_auth/flows/v1/urls.py b/backend/src/zango/api/app_auth/flows/v1/urls.py new file mode 100644 index 000000000..44cf0d3bc --- /dev/null +++ b/backend/src/zango/api/app_auth/flows/v1/urls.py @@ -0,0 +1,32 @@ +from django.urls import path + +from .login import AppLoginViewAPIV1, UserRoleViewAPIV1 +from .login_with_code import ConfirmLoginCodeViewAPIV1, RequestLoginCodeViewAPIV1 +from .logout import AppLogoutViewAPIV1 +from .mfa import GetMFACodeViewAPIV1, MFAVerifyViewAPIV1 +from .password import PasswordChangeViewAPIV1, SetPasswordViewAPIV1 + + +urlpatterns = [ + path( + "login/code/request/", + RequestLoginCodeViewAPIV1.as_api_view(client="browser"), + name="request_login_code", + ), + path( + "login/code/confirm/", + ConfirmLoginCodeViewAPIV1.as_api_view(client="browser"), + name="confirm_login_code", + ), + path("login/", AppLoginViewAPIV1.as_api_view(client="browser"), name="app-login"), + path("logout/", AppLogoutViewAPIV1.as_api_view(client="browser"), name="logout"), + path("role/change/", UserRoleViewAPIV1.as_view(), name="set-role"), + path("password/change/", PasswordChangeViewAPIV1.as_view(), name="change-password"), + path("mfa/getcode/", GetMFACodeViewAPIV1.as_view(), name="mfa-authenticate-view"), + path( + "mfa/verify/", + MFAVerifyViewAPIV1.as_api_view(client="browser"), + name="mfa-verify-view", + ), + path("password/set/", SetPasswordViewAPIV1.as_view(), name="account_set_password"), +] diff --git a/backend/src/zango/api/app_auth/profile/v1/urls.py b/backend/src/zango/api/app_auth/profile/v1/urls.py index 694854647..f65bae8d9 100644 --- a/backend/src/zango/api/app_auth/profile/v1/urls.py +++ b/backend/src/zango/api/app_auth/profile/v1/urls.py @@ -1,11 +1,8 @@ from django.urls import re_path -from .views import PasswordChangeViewAPIV1, ProfileViewAPIV1 +from .views import ProfileViewAPIV1 urlpatterns = [ - re_path( - r"change_password", PasswordChangeViewAPIV1.as_view(), name="change_password" - ), re_path(r"", ProfileViewAPIV1.as_view(), name="profile"), ] diff --git a/backend/src/zango/api/app_auth/profile/v1/views.py b/backend/src/zango/api/app_auth/profile/v1/views.py index 5059c5a94..2fdb53588 100644 --- a/backend/src/zango/api/app_auth/profile/v1/views.py +++ b/backend/src/zango/api/app_auth/profile/v1/views.py @@ -1,11 +1,5 @@ -from django.contrib.auth import authenticate -from django.core.exceptions import ValidationError - -from zango.api.app_auth.profile.v1.utils import PasswordValidationMixin -from zango.apps.appauth.models import OldPasswords from zango.core.api import ( ZangoGenericAppAPIView, - ZangoSessionAppAPIView, get_api_response, ) @@ -28,50 +22,3 @@ def put(self, request, *args, **kwargs): else: status = 400 return get_api_response(success, response, status) - - -class PasswordChangeViewAPIV1(ZangoSessionAppAPIView, PasswordValidationMixin): - def clean_password(self, email, password): - """ - Validates that the email is not already in use. - """ - try: - user = authenticate(username=email, password=password) - except Exception: - raise ValidationError( - "The current password you have entered is wrong. Please try again!" - ) - - def clean_password2(self, user, current_password, new_password): - """method to validate password""" - password2 = new_password - validation = self.run_all_validations( - user, new_password, password2, current_password - ) - if not validation.get("validation"): - raise ValidationError(validation.get("msg")) - return True - - def put(self, request, *args, **kwargs): - current_password = request.data.get("current_password") - new_password = request.data.get("new_password") - success = False - try: - self.clean_password(request.user.email, current_password) - self.clean_password2(request.user, current_password, new_password) - request.user.set_password(new_password) - request.user.save() - obj = OldPasswords.objects.create(user=request.user) - obj.setPasswords(request.user.password) - obj.save() - success = True - response = {} - status = 200 - return get_api_response(success, response, status) - except ValidationError as e: - response = {"message": e.message} - if success: - status = 200 - else: - status = 400 - return get_api_response(success, response, status) diff --git a/backend/src/zango/api/app_auth/urls.py b/backend/src/zango/api/app_auth/urls.py index ca176634d..e281f2569 100644 --- a/backend/src/zango/api/app_auth/urls.py +++ b/backend/src/zango/api/app_auth/urls.py @@ -1,8 +1,12 @@ from django.urls import include, path +from .config.v1 import urls as config_v1_urls +from .flows.v1 import urls as flows_v1_urls from .profile.v1 import urls as profile_v1_urls urlpatterns = [ path("v1/profile/", include(profile_v1_urls)), + path("v1/appauth/config/", include(config_v1_urls)), + path("v1/appauth/", include(flows_v1_urls)), ] diff --git a/backend/src/zango/api/platform/tenancy/v1/serializers.py b/backend/src/zango/api/platform/tenancy/v1/serializers.py index 8251c4e29..367a2ffd0 100644 --- a/backend/src/zango/api/platform/tenancy/v1/serializers.py +++ b/backend/src/zango/api/platform/tenancy/v1/serializers.py @@ -4,7 +4,9 @@ from zango.api.platform.permissions.v1.serializers import PolicySerializer from zango.apps.appauth.models import AppUserModel, UserRoleModel +from zango.apps.appauth.utils import UserRoleAuthConfig from zango.apps.shared.tenancy.models import Domain, TenantModel, ThemesModel +from zango.apps.shared.tenancy.utils import AuthConfigSchema as TenantAuthConfigSchema class DomainSerializerModel(serializers.ModelSerializer): @@ -38,6 +40,9 @@ def get_datetime_format_display(self, obj): def get_date_format_display(self, obj): return obj.get_date_format_display() + def validate_auth_config(self, value: TenantAuthConfigSchema): + return value + def update(self, instance, validated_data): request = self.context["request"] extra_config_str = request.data.get("extra_config") @@ -70,6 +75,9 @@ def update(self, instance, validated_data): request = self.context["request"] domains = request.data.getlist("domains") + if not domains: + return instance + # Removing existing domains domains_to_be_removed = instance.domains.all().exclude(domain__in=domains) domains_to_be_removed.delete() @@ -91,26 +99,28 @@ class UserRoleSerializerModel(serializers.ModelSerializer): class Meta: model = UserRoleModel - fields = [ - "id", - "name", - "is_active", - "is_default", - "no_of_users", - "policies", - "attached_policies", - "policy_groups", - "created_at", - "created_by", - "modified_at", - "modified_by", - ] + fields = "__all__" def get_attached_policies(self, obj): policies = obj.policies.all() policy_serializer = PolicySerializer(policies, many=True) return policy_serializer.data + def validate_auth_config(self, value: UserRoleAuthConfig): + tenant = self.context.get("tenant") + tenant_auth_config = tenant.auth_config + + # Validate two_factor_auth config not overridden by role + if tenant_auth_config.get("two_factor_auth", {}).get("required", False): + if value.get("two_factor_auth", {}).get("required", False) is False: + raise serializers.ValidationError( + "Two-factor authentication is required for this user role as it is enabled for the tenant." + ) + return value + + def validate(self, attrs): + return attrs + def update(self, instance, validated_data): if not validated_data.get("policies"): validated_data["policies"] = [] @@ -123,23 +133,36 @@ class AppUserModelSerializerModel(serializers.ModelSerializer): class Meta: model = AppUserModel - fields = [ - "id", - "name", - "email", - "mobile", - "roles", - "is_active", - "last_login", - "created_at", - "pn_country_code", - ] + fields = "__all__" def get_pn_country_code(self, obj): if obj.mobile: return f"+{obj.mobile.country_code}" return None + def validate_user_role_two_factor_not_overridden(self, auth_config, roles): + user_two_factor_auth_enabled = auth_config.get("two_factor_auth", {}).get( + "required" + ) + for role in roles: + if role.auth_config.get("two_factor_auth", {}): + two_factor_auth = auth_config["two_factor_auth"] + if two_factor_auth.get("required") and not user_two_factor_auth_enabled: + raise serializers.ValidationError( + "Two-factor authentication is required for this user role." + ) + + def validate(self, attrs): + auth_config = attrs.get("auth_config", {}) + if auth_config: + self.validate_user_role_two_factor_not_overridden( + auth_config, attrs.get("roles") + ) + return attrs + + def validate_auth_config(self, value): + return value + class ThemeModelSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/src/zango/api/platform/tenancy/v1/views.py b/backend/src/zango/api/platform/tenancy/v1/views.py index c822410e0..6080e3a0d 100644 --- a/backend/src/zango/api/platform/tenancy/v1/views.py +++ b/backend/src/zango/api/platform/tenancy/v1/views.py @@ -3,6 +3,7 @@ from django_celery_results.models import TaskResult +from django.core.exceptions import ValidationError from django.db import connection from django.db.models import Q from django.utils.decorators import method_decorator @@ -13,6 +14,7 @@ from zango.apps.shared.tenancy.models import TenantModel, ThemesModel from zango.apps.shared.tenancy.utils import DATEFORMAT, DATETIMEFORMAT, TIMEZONES from zango.core.api import ( + TenantMixin, ZangoGenericPlatformAPIView, get_api_response, ) @@ -140,13 +142,9 @@ def post(self, request, *args, **kwargs): return get_api_response(success, result, status) -class AppDetailViewAPIV1(ZangoGenericPlatformAPIView): +class AppDetailViewAPIV1(ZangoGenericPlatformAPIView, TenantMixin): permission_classes = (IsPlatformUserAllowedApp,) - def get_obj(self, **kwargs): - obj = TenantModel.objects.get(uuid=kwargs.get("app_uuid")) - return obj - def get_dropdown_options(self): options = {} options["timezones"] = [{"id": t[0], "label": t[1]} for t in TIMEZONES] @@ -158,9 +156,9 @@ def get_dropdown_options(self): def get(self, request, *args, **kwargs): try: - obj = self.get_obj(**kwargs) + tenant = self.get_tenant(**kwargs) include_dropdown_options = request.GET.get("include_dropdown_options") - serializer = TenantSerializerModel(obj) + serializer = TenantSerializerModel(tenant) success = True response = {"app": serializer.data} if include_dropdown_options: @@ -180,7 +178,7 @@ def get_branch(self, config, key, default=None): def put(self, request, *args, **kwargs): try: - obj = self.get_obj(**kwargs) + obj = self.get_tenant(**kwargs) serializer = TenantSerializerModel( instance=obj, data=request.data, @@ -315,7 +313,7 @@ def post(self, request, *args, **kwargs): @method_decorator(set_app_schema_path, name="dispatch") -class UserRoleDetailViewAPIV1(ZangoGenericPlatformAPIView): +class UserRoleDetailViewAPIV1(ZangoGenericPlatformAPIView, TenantMixin): permission_classes = (IsPlatformUserAllowedApp,) def get_obj(self, **kwargs): @@ -339,11 +337,12 @@ def get(self, request, *args, **kwargs): def put(self, request, *args, **kwargs): try: obj = self.get_obj(**kwargs) + tenant = self.get_tenant(**kwargs) serializer = UserRoleSerializerModel( instance=obj, data=request.data, partial=True, - context={"request": request}, + context={"request": request, "tenant": tenant}, ) if serializer.is_valid(): serializer.save() @@ -383,14 +382,10 @@ def put(self, request, *args, **kwargs): @method_decorator(set_app_schema_path, name="dispatch") -class UserViewAPIV1(ZangoGenericPlatformAPIView, ZangoAPIPagination): +class UserViewAPIV1(ZangoGenericPlatformAPIView, ZangoAPIPagination, TenantMixin): pagination_class = ZangoAPIPagination permission_classes = (IsPlatformUserAllowedApp,) - def get_app_tenant(self): - tenant_obj = TenantModel.objects.get(uuid=self.kwargs["app_uuid"]) - return tenant_obj - def get_dropdown_options(self): options = {} options["roles"] = [ @@ -437,7 +432,7 @@ def get(self, request, *args, **kwargs): app_users = self.paginate_queryset(app_users, request, view=self) serializer = AppUserModelSerializerModel(app_users, many=True) app_users_data = self.get_paginated_response_data(serializer.data) - app_tenant = self.get_app_tenant() + app_tenant = self.get_tenant(**kwargs) success = True response = { "users": app_users_data, @@ -455,6 +450,43 @@ def get(self, request, *args, **kwargs): return get_api_response(success, response, status) + def validate_email_and_phone_passed( + self, tenant_auth_config, user_auth_config, email, phone + ): + login_methods = tenant_auth_config.get("login_methods") + if login_methods.get("otp", {}).get("enabled", False): + allowed_methods = login_methods["otp"].get("allowed_methods") + if "email" in allowed_methods and not email: + raise ValidationError( + "Email is required since email based login is enabled" + ) + if "sms" in allowed_methods and not phone: + raise ValidationError( + "Phone is required since SMS based login is enabled" + ) + + if login_methods.get("two_factor_auth", {}).get("required", False): + allowed_methods = login_methods["two_factor_auth"]["allowed_methods"] + if not email or not phone: + raise ValidationError("Email and Phone are required") + + if login_methods.get("password", {}).get("enabled", False): + allowed_methods = login_methods["password"].get("allowed_usernames", []) + if "email" in allowed_methods and not email: + raise ValidationError( + "Email is required since email based login is enabled" + ) + if "phone" in allowed_methods and not phone: + raise ValidationError( + "Phone is required since SMS based login is enabled" + ) + + def validate_password_passed(self, tenant_auth_config, user_auth_config, password): + login_methods = tenant_auth_config.get("login_methods") + if login_methods.get("password", {}).get("enabled", False): + if not password: + raise ValidationError("Password is required for password based login") + def post(self, request, *args, **kwargs): data = request.data try: @@ -463,6 +495,16 @@ def post(self, request, *args, **kwargs): if not validate_phone(data["mobile"]): result = {"message": "Invalid mobile number"} return get_api_response(False, result, 400) + app_tenant = self.get_tenant(**kwargs) + self.validate_email_and_phone_passed( + app_tenant.auth_config, + data.get("auth_config"), + data.get("email"), + data.get("mobile"), + ) + self.validate_password_passed( + app_tenant.auth_config, data.get("auth_config"), data.get("password") + ) creation_result = AppUserModel.create_user( name=data["name"], email=data["email"], @@ -518,6 +560,24 @@ def put(self, request, *args, **kwargs): success = update_result["success"] message = update_result["message"] status_code = 200 if success else 400 + if status_code != 400: + if request.data.get("auth_config"): + ser = AppUserModelSerializerModel( + instance=obj, data=request.data["auth_config"], partial=True + ) + if ser.is_valid(): + ser.save() + else: + if ser.errors: + error_messages = [ + error[0] for field_name, error in ser.errors.items() + ] + error_message = ", ".join(error_messages) + else: + error_message = "Invalid data" + + result = {"message": error_message} + return get_api_response(False, result, 400) result = { "message": message, "user_id": obj.id, diff --git a/backend/src/zango/apps/appauth/auth_backend.py b/backend/src/zango/apps/appauth/auth_backend.py index a0a0129e0..1ce9a162a 100644 --- a/backend/src/zango/apps/appauth/auth_backend.py +++ b/backend/src/zango/apps/appauth/auth_backend.py @@ -20,10 +20,17 @@ class AppUserModelBackend(ModelBackend): - def authenticate(self, request, username=None, password=None): + def authenticate( + self, request, username=None, password=None, email=None, phone=None + ): if request and request.tenant.tenant_type == "app": try: - user = AppUserModel.objects.get(Q(email=username) | Q(mobile=username)) + user = AppUserModel.objects.get( + Q(email=username) + | Q(mobile=username) + | Q(email=email) + | Q(mobile=phone) + ) pwd_valid = user.check_password(password) if pwd_valid and user.is_active: return user diff --git a/backend/src/zango/apps/appauth/migrations/0008_appusermodel_auth_config_userrolemodel_auth_config_and_more.py b/backend/src/zango/apps/appauth/migrations/0008_appusermodel_auth_config_userrolemodel_auth_config_and_more.py new file mode 100644 index 000000000..0117f9f27 --- /dev/null +++ b/backend/src/zango/apps/appauth/migrations/0008_appusermodel_auth_config_userrolemodel_auth_config_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.16 on 2025-07-18 11:56 + +from django.db import migrations, models +import django.db.models.deletion +import zango.apps.appauth.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('appauth', '0007_appuserauthtoken'), + ] + + operations = [ + migrations.AddField( + model_name='appusermodel', + name='auth_config', + field=models.JSONField(default=zango.apps.appauth.utils.get_default_app_user_auth_config), + ), + migrations.AddField( + model_name='userrolemodel', + name='auth_config', + field=models.JSONField(default=zango.apps.appauth.utils.get_default_user_role_auth_config), + ), + migrations.CreateModel( + name='OTPCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.CharField(blank=True, editable=False, max_length=255)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('modified_by', models.CharField(blank=True, editable=False, max_length=255)), + ('code', models.CharField(max_length=128)), + ('otp_type', models.CharField(choices=[('two_factor_auth', 'Two-Factor Authentication'), ('login_code', 'Login Code')], max_length=50)), + ('is_used', models.BooleanField(default=False)), + ('expires_at', models.DateTimeField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='otp_codes', to='appauth.appusermodel')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/src/zango/apps/appauth/models.py b/backend/src/zango/apps/appauth/models.py index 80f2614c7..780fe016b 100644 --- a/backend/src/zango/apps/appauth/models.py +++ b/backend/src/zango/apps/appauth/models.py @@ -1,3 +1,5 @@ +import secrets + from datetime import date, timedelta from knox.models import AbstractAuthToken @@ -7,6 +9,7 @@ from django.contrib.auth.hashers import check_password from django.db import models from django.db.models import Q +from django.utils import timezone from zango.apps.auditlogs.registry import auditlog from zango.apps.object_store.models import ObjectStore @@ -15,11 +18,13 @@ AbstractZangoUserModel, ) from zango.core.model_mixins import FullAuditMixin +from zango.core.utils import get_auth_priority from ..permissions.mixin import PermissionMixin # from .perm_mixin import PolicyQsMixin from ..permissions.models import PolicyGroupModel, PolicyModel +from .utils import get_default_app_user_auth_config, get_default_user_role_auth_config class UserRoleModel(FullAuditMixin, PermissionMixin): @@ -33,6 +38,7 @@ class UserRoleModel(FullAuditMixin, PermissionMixin): config = models.JSONField(null=True, blank=True) is_active = models.BooleanField(default=True) is_default = models.BooleanField(default=False) + auth_config = models.JSONField(default=get_default_user_role_auth_config) def __str__(self): return self.name @@ -62,6 +68,7 @@ class AppUserModel(AbstractZangoUserModel, PermissionMixin): PolicyGroupModel, related_name="user_policy_groups" ) app_objects = models.JSONField(null=True) + auth_config = models.JSONField(default=get_default_app_user_auth_config) def generate_auth_token(self, role, expiry=knox_settings.TOKEN_TTL): try: @@ -107,22 +114,55 @@ def has_perm(self, request, perm_type, view=None, dataModel=None): @classmethod def validate_password(cls, password): - """ - Password Rule for App Users: Maintain the rule in AppConfig or use default - { - 'password_rule': {} - } - """ import re - reg = ( - r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!#%*?&]{8,18}$" - ) - match_re = re.compile(reg) + policy = get_auth_priority(policy="password_policy") + + # Default policy if none provided + default_policy = { + "min_length": 8, + "require_numbers": True, + "require_lowercase": True, + "require_uppercase": True, + "require_special_chars": False, + } + + # Use provided policy or default + if policy is None: + policy = default_policy + + # Check minimum length + min_length = policy.get("min_length", 8) + if len(password) < min_length: + return False + + # Build regex pattern based on policy + regex_parts = [] + + # Check for lowercase letters + if policy.get("require_lowercase", True): + regex_parts.append(r"(?=.*[a-z])") + + # Check for uppercase letters + if policy.get("require_uppercase", True): + regex_parts.append(r"(?=.*[A-Z])") + + # Check for numbers + if policy.get("require_numbers", True): + regex_parts.append(r"(?=.*\d)") + + # Check for special characters + if policy.get("require_special_chars", False): + regex_parts.append(r"(?=.*[@$!%*#?&])") + + # Combine all lookahead assertions + regex_pattern = "^" + "".join(regex_parts) + r"[A-Za-z\d@$!#%*?&]+$" + + # Compile and test the regex + match_re = re.compile(regex_pattern) res = re.search(match_re, password) - if res: - return True - return False + + return res is not None def add_roles(self, role_ids): self.roles.clear() @@ -148,7 +188,7 @@ def create_user( name, email, mobile, - password, + password=None, role_ids=[], force_password_reset=True, require_verification=True, @@ -173,41 +213,50 @@ def create_user( "Another user already exists matching the provided credentials" ) else: - if not cls.validate_password(password): - message = """ - Invalid password. Password must follow rules - 1. Must have at least 8 characters - 2. Must have at least one uppercase letter - 3. Must have at least one lowercase letter - 4. Must have at least one number - 5. Must have at least one special character - """ - else: - app_user = cls.objects.create( - name=name, - email=email, - mobile=mobile, - ) - app_user.add_roles(role_ids) + if password: + if not cls.validate_password(password): + message = """ + Invalid password. Password must follow rules + 1. Must have at least 8 characters + 2. Must have at least one uppercase letter + 3. Must have at least one lowercase letter + 4. Must have at least one number + 5. Must have at least one special character + """ + return { + "success": success, + "message": message, + "app_user": app_user, + } + + app_user = cls.objects.create( + name=name, + email=email, + mobile=mobile, + ) + app_user.add_roles(role_ids) + + if password: app_user.set_password(password) - if require_verification: - app_user.is_active = False - else: - app_user.is_active = True - - if not force_password_reset: - old_password_obj = OldPasswords.objects.create( - user=app_user - ) - old_password_obj.setPasswords(app_user.password) - old_password_obj.save() - - if app_objects: - app_user.app_objects = app_objects - - app_user.save() - success = True - message = "App User created successfully." + else: + app_user.set_unusable_password() + + if require_verification: + app_user.is_active = False + else: + app_user.is_active = True + + if password and not force_password_reset: + old_password_obj = OldPasswords.objects.create(user=app_user) + old_password_obj.setPasswords(app_user.password) + old_password_obj.save() + + if app_objects: + app_user.app_objects = app_objects + + app_user.save() + success = True + message = "App User created successfully." except Exception as e: message = str(e) return {"success": success, "message": message, "app_user": app_user} @@ -291,6 +340,22 @@ def has_password_reset_step(self, request, days=90): class OldPasswords(AbstractOldPasswords): user = models.ForeignKey(AppUserModel, on_delete=models.PROTECT) + def clean_old_passwords(self): + from zango.core.utils import get_auth_priority + + password_policy = get_auth_priority(policy="password_policy", user=self.user) + password_history_count = password_policy.get("password_history_count", 3) + + old_passwords = OldPasswords.objects.filter(user=self.user).order_by( + "created_at" + ) + extra_passwords = old_passwords.count() - password_history_count + + if extra_passwords > 0: + to_delete = old_passwords[:extra_passwords] + for old_pw in to_delete: + old_pw.delete() + class AppUserAuthToken(AbstractAuthToken): user = models.ForeignKey( @@ -310,7 +375,82 @@ class AppUserAuthToken(AbstractAuthToken): ) +class OTPCode(FullAuditMixin): + """ + Model to store various types of One-Time Passwords (OTPs) and login codes for users. + """ + + OTP_TYPE_CHOICES = ( + ("two_factor_auth", "Two-Factor Authentication"), + ("login_code", "Login Code"), + ) + + user = models.ForeignKey( + AppUserModel, + on_delete=models.CASCADE, + related_name="otp_codes", + ) + code = models.CharField( + max_length=128, + ) + otp_type = models.CharField( + max_length=50, + choices=OTP_TYPE_CHOICES, + ) + is_used = models.BooleanField( + default=False, + ) + expires_at = models.DateTimeField() + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.user.name}'s {self.get_otp_type_display()} code ({self.code})" + + def is_valid(self): + """ + Checks if the OTP code is still valid (not expired and not used). + """ + return not self.is_used and self.expires_at > timezone.now() + + def mark_as_used(self): + """ + Marks the OTP code as used. + """ + self.delete() + + +def generate_otp(otp_type, user=None, email=None, phone=None, expires_at=5, digits=6): + try: + if not user: + if email: + user = AppUserModel.objects.filter(email=email).first() + elif phone: + user = AppUserModel.objects.filter(mobile=phone).first() + + min_value = 10 ** (digits - 1) + max_value = 10**digits - 1 + + code = str(secrets.randbelow(max_value - min_value + 1) + min_value) + expires_at = timezone.now() + timezone.timedelta(minutes=expires_at) + OTPCode.objects.filter(user=user, otp_type=otp_type).delete() + return OTPCode.objects.create( + user=user, + code=code, + otp_type=otp_type, + is_used=False, + expires_at=expires_at, + ).code + except Exception as e: + import traceback + + traceback.print_exc() + return "" + + auditlog.register(AppUserModel, m2m_fields={"policies", "roles", "policy_groups"}) auditlog.register(OldPasswords) auditlog.register(UserRoleModel, m2m_fields={"policy_groups", "policies"}) auditlog.register(AppUserAuthToken) +auditlog.register(OTPCode, exclude_fields=["code"]) diff --git a/backend/src/zango/apps/appauth/tasks.py b/backend/src/zango/apps/appauth/tasks.py new file mode 100644 index 000000000..da4e28aa9 --- /dev/null +++ b/backend/src/zango/apps/appauth/tasks.py @@ -0,0 +1,307 @@ +from typing import Any, Dict, Optional + +import requests + +from celery import shared_task +from requests.exceptions import RequestException, Timeout + +from django.core.exceptions import ObjectDoesNotExist +from django.db import connection + +from zango.apps.appauth.models import AppUserModel, UserRoleModel, generate_otp +from zango.apps.shared.tenancy.models import TenantModel +from zango.core.utils import ( + get_auth_priority, + get_mock_request, + get_package_url, +) + + +class OTPConfig: + """Configuration for OTP sending""" + + def __init__( + self, + method: str, + otp_type: str, + message: str, + tenant_id: int, + email: Optional[str] = None, + phone: Optional[str] = None, + user_id: Optional[int] = None, + request_data: Optional[Dict[str, Any]] = None, + user_role_id: Optional[int] = None, + subject: Optional[str] = None, + code: Optional[str] = None, + ): + self.method = method + self.otp_type = otp_type + self.message = message + self.tenant_id = tenant_id + self.email = email + self.phone = phone + self.user_id = user_id + self.request_data = request_data + self.user_role_id = user_role_id + self.subject = subject + self.code = code + + +class OTPSendError(Exception): + """Custom exception for OTP sending errors""" + + pass + + +class OTPService: + """Service class to handle OTP operations""" + + SUPPORTED_METHODS = {"email", "sms"} + SUPPORTED_OTP_TYPES = {"two_factor_auth", "login_code"} + REQUEST_TIMEOUT = 30 + + def __init__(self, config: OTPConfig): + self.config = config + self.tenant = None + self.user = None + self.user_role = None + self.request = None + + def _validate_config(self) -> None: + """Validate the configuration parameters""" + if self.config.method not in self.SUPPORTED_METHODS: + raise OTPSendError(f"Unsupported method: {self.config.method}") + + if self.config.otp_type not in self.SUPPORTED_OTP_TYPES: + raise OTPSendError(f"Unsupported OTP type: {self.config.otp_type}") + + if not any([self.config.user_id, self.config.email, self.config.phone]): + raise OTPSendError( + "At least one of user_id, email, or phone must be provided" + ) + + def _setup_tenant(self) -> None: + """Setup tenant and database connection""" + try: + self.tenant = TenantModel.objects.get(id=self.config.tenant_id) + connection.set_tenant(self.tenant) + except ObjectDoesNotExist: + raise OTPSendError(f"Tenant with id {self.config.tenant_id} not found") + + def _setup_user(self) -> None: + """Setup user based on provided identifiers""" + try: + if self.config.user_id: + self.user = AppUserModel.objects.get(id=self.config.user_id) + elif self.config.email: + self.user = AppUserModel.objects.get(email=self.config.email) + elif self.config.phone: + self.user = AppUserModel.objects.get(mobile=self.config.phone) + except ObjectDoesNotExist: + raise OTPSendError("User not found with provided identifiers") + + def _setup_user_role(self) -> None: + """Setup user role if provided""" + if self.config.user_role_id: + try: + self.user_role = UserRoleModel.objects.get(id=self.config.user_role_id) + except ObjectDoesNotExist: + raise OTPSendError( + f"User role with id {self.config.user_role_id} not found" + ) + + def _setup_request(self) -> None: + """Setup mock request object""" + request_data = self.config.request_data or {} + self.request = get_mock_request(path=request_data.get("path")) + self.request.META = {"HTTP_HOST": self.tenant.get_primary_domain()} + self.request.user = self.user + + def _get_policy_config(self, policy_name: str) -> Dict[str, Any]: + """Get policy configuration for the given policy name""" + return get_auth_priority( + policy=policy_name, + request=self.request, + user=self.user, + user_role=self.user_role, + tenant=self.tenant, + ) + + def _send_email(self, message: str, url: str) -> None: + """Send OTP via email""" + if not self.user.email: + raise OTPSendError("User email not available") + + payload = { + "body": message, + "to": self.user.email, + "subject": self.config.subject or "OTP Verification", + } + + try: + response = requests.post(url, data=payload, timeout=self.REQUEST_TIMEOUT) + response.raise_for_status() + except Timeout: + raise OTPSendError("Email service request timed out") + except RequestException as e: + raise OTPSendError(f"Failed to send email: {str(e)}") + + def _send_sms(self, message: str, url: str) -> None: + """Send OTP via SMS""" + if not self.user.mobile: + raise OTPSendError("User mobile number not available") + + payload = {"message": message, "to": str(self.user.mobile)} + + try: + response = requests.post(url, data=payload, timeout=self.REQUEST_TIMEOUT) + response.raise_for_status() + except Timeout: + raise OTPSendError("SMS service request timed out") + except RequestException as e: + raise OTPSendError(f"Failed to send SMS: {str(e)}") + + def _get_service_url(self, method_config: Dict[str, Any], service_type: str) -> str: + """Get service URL for email or SMS""" + url = method_config.get("url") + if not url: + endpoint = f"{service_type}/api/?action=send" + url = get_package_url(self.request, endpoint, "communication") + return url + + def _handle_two_factor_auth(self) -> None: + """Handle two-factor authentication OTP""" + policy = self._get_policy_config("two_factor_auth") + + if not policy.get("required", False): + return + + if not self.config.code: + otp = generate_otp(self.config.otp_type, self.user) + message = f"{self.config.message} {otp}" + else: + message = f"{self.config.message} {self.config.code}" + + if self.config.method == "email": + email_config = policy.get("email", {}) + url = self._get_service_url(email_config, "email") + self._send_email(message, url) + elif self.config.method == "sms": + sms_config = policy.get("sms", {}) + url = self._get_service_url(sms_config, "sms") + self._send_sms(message, url) + + def _handle_login_code(self) -> None: + """Handle login code OTP""" + policy = self._get_policy_config("login_methods") + + if not policy.get("otp", {}).get("enabled", False): + return + + if not self.config.code: + otp = generate_otp(self.config.otp_type, self.user) + message = f"{self.config.message} {otp}" + else: + message = f"{self.config.message} {self.config.code}" + + if self.config.method == "email": + email_config = policy.get("email", {}) + url = self._get_service_url(email_config, "email") + self._send_email(message, url) + elif self.config.method == "sms": + sms_config = policy.get("sms", {}) + url = self._get_service_url(sms_config, "sms") + self._send_sms(message, url) + + def send_otp(self) -> Dict[str, Any]: + """Main method to send OTP""" + try: + self._validate_config() + self._setup_tenant() + self._setup_user() + self._setup_user_role() + self._setup_request() + + if self.config.otp_type == "two_factor_auth": + self._handle_two_factor_auth() + elif self.config.otp_type == "login_code": + self._handle_login_code() + + return { + "success": True, + "message": "OTP sent successfully", + "method": self.config.method, + "otp_type": self.config.otp_type, + } + + except OTPSendError as e: + return {"success": False, "message": str(e), "error_type": "OTP_SEND_ERROR"} + except Exception as e: + return { + "success": False, + "message": "An unexpected error occurred while sending OTP", + "error_type": "UNEXPECTED_ERROR", + } + + +@shared_task(bind=True, max_retries=3, default_retry_delay=60) +def send_otp( + self, + method: str, + otp_type: str, + message: str, + tenant_id: int, + email: Optional[str] = None, + phone: Optional[str] = None, + user_id: Optional[int] = None, + request_data: Optional[Dict[str, Any]] = None, + user_role_id: Optional[int] = None, + subject: Optional[str] = None, + code: Optional[str] = None, +) -> Dict[str, Any]: + """ + Celery task to send OTP via email or SMS. + + Args: + method: Communication method ('email' or 'sms') + otp_type: Type of OTP ('two_factor_auth' or 'login_code') + message: Base message to send + tenant_id: ID of the tenant + email: User email (optional) + phone: User phone (optional) + user_id: User ID (optional) + request_data: Additional request data (optional) + user_role_id: User role ID (optional) + subject: Email subject (optional) + + Returns: + Dict containing success status and message + """ + config = OTPConfig( + method=method, + otp_type=otp_type, + message=message, + tenant_id=tenant_id, + email=email, + phone=phone, + user_id=user_id, + request_data=request_data, + user_role_id=user_role_id, + subject=subject, + code=code, + ) + + service = OTPService(config) + result = service.send_otp() + + # Retry logic for specific errors + if not result["success"] and result.get("error_type") in [ + "TIMEOUT", + "REQUEST_ERROR", + ]: + try: + raise self.retry(countdown=60 * (2**self.request.retries)) + except self.MaxRetriesExceededError: + result["message"] = "Failed to send OTP after multiple attempts" + + return result diff --git a/backend/src/zango/apps/appauth/utils.py b/backend/src/zango/apps/appauth/utils.py new file mode 100644 index 000000000..b3e8c036d --- /dev/null +++ b/backend/src/zango/apps/appauth/utils.py @@ -0,0 +1,36 @@ +from typing import List, Literal, Required, TypedDict + +from zango.apps.shared.tenancy.utils import PasswordPolicy + + +class TwoFactorAuth(TypedDict, total=False): + required: Required[bool] + allowed_methods: List[Literal["email", "sms", "totp"]] + + +class UserRoleAuthConfig(TypedDict, total=False): + password_policy: PasswordPolicy + two_factor_auth: TwoFactorAuth + redirect_url: Required[str] + + +class SSOIdentity(TypedDict): + provider: str + identity_id: str + + +class AppUserAuthConfig(TypedDict, total=False): + two_factor_auth: TwoFactorAuth + sso_identities: List[SSOIdentity] + + +USER_ROLE_AUTH_CONFIG: UserRoleAuthConfig = {"redirect_url": "/frame/router/"} +APP_USER_AUTH_CONFIG: AppUserAuthConfig = {} + + +def get_default_user_role_auth_config(): + return USER_ROLE_AUTH_CONFIG + + +def get_default_app_user_auth_config(): + return APP_USER_AUTH_CONFIG diff --git a/backend/src/zango/apps/shared/tenancy/migrations/0006_tenantmodel_auth_config.py b/backend/src/zango/apps/shared/tenancy/migrations/0006_tenantmodel_auth_config.py new file mode 100644 index 000000000..eaa4ef7f6 --- /dev/null +++ b/backend/src/zango/apps/shared/tenancy/migrations/0006_tenantmodel_auth_config.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.16 on 2025-07-18 11:56 + +from django.db import migrations, models +import zango.apps.shared.tenancy.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0005_tenantmodel_app_template'), + ] + + operations = [ + migrations.AddField( + model_name='tenantmodel', + name='auth_config', + field=models.JSONField(default=zango.apps.shared.tenancy.utils.get_default_auth_config), + ), + ] diff --git a/backend/src/zango/apps/shared/tenancy/models.py b/backend/src/zango/apps/shared/tenancy/models.py index 7d4f58d7b..62d160c45 100644 --- a/backend/src/zango/apps/shared/tenancy/models.py +++ b/backend/src/zango/apps/shared/tenancy/models.py @@ -19,11 +19,7 @@ from zango.core.storage_utils import ZFileField from .tasks import initialize_workspace -from .utils import ( - DATEFORMAT, - DATETIMEFORMAT, - TIMEZONES, -) +from .utils import DATEFORMAT, DATETIMEFORMAT, TIMEZONES, get_default_auth_config Choice = namedtuple("Choice", ["value", "display"]) @@ -123,6 +119,7 @@ class TenantModel(TenantMixin, FullAuditMixin): app_template = ZFileField( verbose_name="template used for app", null=True, blank=True ) + auth_config = models.JSONField(default=get_default_auth_config) auto_create_schema = False diff --git a/backend/src/zango/apps/shared/tenancy/utils.py b/backend/src/zango/apps/shared/tenancy/utils.py index 5ea81e8b5..7322fe7d4 100644 --- a/backend/src/zango/apps/shared/tenancy/utils.py +++ b/backend/src/zango/apps/shared/tenancy/utils.py @@ -1,8 +1,119 @@ +from typing import List, Literal, Optional, Required, TypedDict + import pytz from django_tenants.utils import schema_context +class PasswordReset(TypedDict, total=False): + enabled: Required[bool] + method: List[Literal["email", "sms", "both"]] + expiry_hours: int + sms_hook: str + email_hook: str + + +class PasswordPolicy(TypedDict): + min_length: int + require_uppercase: bool + require_lowercase: bool + require_numbers: bool + require_special_chars: bool + password_history_count: int + password_expiry_days: int + allow_change: bool + reset: PasswordReset + + +class PasswordLoginMethod(TypedDict, total=False): + enabled: bool + forgot_password_enabled: bool + password_reset_link_expiry_hours: int + allowed_usernames: List[Literal["email", "phone"]] + + +class SSOLoginMethod(TypedDict): + enabled: bool + + +class OIDCLoginMethod(TypedDict): + enabled: bool + + +class OTPLoginMethod(TypedDict, total=False): + enabled: Required[bool] + allowed_methods: List[Literal["sms", "email"]] + email_hook: str + sms_hook: str + + +class LoginMethods(TypedDict): + password: PasswordLoginMethod + sso: SSOLoginMethod + oidc: OIDCLoginMethod + otp: OTPLoginMethod + + +class TwoFactorAuth(TypedDict, total=False): + required: bool + enforced_from: Optional[str] + grace_period_days: Optional[int] + allowed_methods: Optional[List[Literal["totp", "sms", "email"]]] + skip_for_sso: Optional[bool] + email_hook: str + sms_hook: str + + +class SessionPolicy(TypedDict): + max_concurrent_sessions: int + force_logout_on_password_change: bool + + +class AuthConfigSchema(TypedDict, total=False): + password_policy: PasswordPolicy + login_methods: LoginMethods + two_factor_auth: TwoFactorAuth + session_policy: SessionPolicy + + +DEFAULT_AUTH_CONFIG: AuthConfigSchema = { + "password_policy": { + "min_length": 8, + "require_uppercase": True, + "require_lowercase": True, + "require_numbers": True, + "require_special_chars": False, + "password_history_count": 3, + "password_expiry_days": 90, + "allow_change": True, + "reset": { + "enabled": True, + "method": ["email"], + "expiry_hours": 2, + }, + }, + "login_methods": { + "password": { + "enabled": True, + "forgot_password_enabled": False, + "allowed_usernames": ["email", "phone"], + }, + "sso": { + "enabled": False, + }, + "oidc": {"enabled": False}, + "otp": {"enabled": False}, + }, + "two_factor_auth": { + "required": False, + }, +} + + +def get_default_auth_config() -> AuthConfigSchema: + return DEFAULT_AUTH_CONFIG + + __all__ = [ "TIMEZONES", "DATEFORMAT", diff --git a/backend/src/zango/config/settings/base.py b/backend/src/zango/config/settings/base.py index 1e1cfa613..1fe73e202 100644 --- a/backend/src/zango/config/settings/base.py +++ b/backend/src/zango/config/settings/base.py @@ -70,6 +70,9 @@ # "cachalot", "axes", "django_recaptcha", + "allauth.account", + "allauth.headless", + "allauth.mfa", ] INSTALLED_APPS = list(SHARED_APPS) + [ @@ -103,6 +106,7 @@ "zango.apps.auditlogs.middleware.AuditlogMiddleware", "axes.middleware.AxesMiddleware", "zango.middleware.telemetry.OtelZangoContextMiddleware", + "allauth.account.middleware.AccountMiddleware", ] AUTHENTICATION_BACKENDS = ( @@ -512,6 +516,8 @@ def setup_settings(settings, BASE_DIR): if settings.HEALTH_CHECK_URL: CELERY_BEAT_SCHEDULE["health_check_task"]["enabled"] = True + settings.HEADLESS_ONLY = True + settings_result = {"env": env} return settings_result diff --git a/backend/src/zango/core/api/__init__.py b/backend/src/zango/core/api/__init__.py index 3bf88ed28..f485aabff 100644 --- a/backend/src/zango/core/api/__init__.py +++ b/backend/src/zango/core/api/__init__.py @@ -5,6 +5,7 @@ ZangoSessionPlatformAPIView, ZangoTokenPlatformAPIView, ) +from .mixin import TenantMixin from .utils import get_api_response @@ -15,4 +16,5 @@ "ZangoSessionPlatformAPIView", "ZangoTokenPlatformAPIView", "get_api_response", + "TenantMixin", ] diff --git a/backend/src/zango/core/api/mixin.py b/backend/src/zango/core/api/mixin.py new file mode 100644 index 000000000..4fd11a61b --- /dev/null +++ b/backend/src/zango/core/api/mixin.py @@ -0,0 +1,6 @@ +class TenantMixin: + def get_tenant(self, **kwargs): + from zango.apps.shared.tenancy.models import TenantModel + + obj = TenantModel.objects.get(uuid=kwargs.get("app_uuid")) + return obj diff --git a/backend/src/zango/core/utils.py b/backend/src/zango/core/utils.py index 7f1a38305..fd0f80741 100644 --- a/backend/src/zango/core/utils.py +++ b/backend/src/zango/core/utils.py @@ -1,6 +1,7 @@ import json from importlib import import_module +from typing import Any, Dict, Literal, Optional, Union import phonenumbers import pytz @@ -232,3 +233,115 @@ def get_app_secret(key=None, id=None): raise ValueError(f"Secret {sec.key} is inactive.") return sec.get_unencrypted_val() + + +AuthLevel = Literal["user", "user_role", "tenant"] + + +def get_auth_priority( + config_key: Optional[str] = None, + policy: Optional[str] = None, + request: Any = None, + user: Any = None, + user_role: Any = None, + tenant: Any = None, +) -> Dict[str, Dict[str, Any]] | Union[str, int, bool, float]: + """ + Check authentication priority for a configuration key across user, role, and tenant levels. + When policy is specified, merges the policy configurations across all levels. + + Args: + config_key: The configuration key to check + policy: Optional policy name to check within auth configs + request: HTTP request object (auto-resolved if None) + user: User object (auto-resolved from request if None) + user_role: User role object (auto-resolved if None) + tenant: Tenant object (auto-resolved from request if None) + + Returns: + If policy is specified: + - The merged policy configuration across all levels + If policy is not specified: + - The value of the configuration key from the first level that has it, + or an empty string if not found at any level + """ + + from zango.apps.appauth.models import UserRoleModel + + if request is None: + request = get_current_request() + if user is None: + user = request.user + if user_role is None: + if getattr(request, "session", None): + if request.session.get("role_id"): + user_role = UserRoleModel.objects.get(id=request.session["role_id"]) + else: + user_role = get_current_role() + if tenant is None: + tenant = request.tenant + + user_auth_config = getattr(user, "auth_config", {}) + user_role_auth_config = getattr(user_role, "auth_config", {}) if user_role else {} + tenant_auth_config = getattr(tenant, "auth_config", {}) + + if not config_key and not policy: + merged_policy = {} + + merged_policy.update(tenant_auth_config) + merged_policy.update(user_role_auth_config) + merged_policy.update(user_auth_config) + return merged_policy + + if policy: + merged_policy = {} + + tenant_policy = tenant_auth_config.get(policy, {}) + if tenant_policy: + merged_policy.update(tenant_policy) + user_role_policy = user_role_auth_config.get(policy, {}) + if user_role_policy: + merged_policy.update(user_role_policy) + user_policy = user_auth_config.get(policy, {}) + if user_policy: + merged_policy.update(user_policy) + return merged_policy + else: + auth_levels = [ + ("user", user_auth_config), + ("user_role", user_role_auth_config), + ("tenant", tenant_auth_config), + ] + + for level, config in auth_levels: + if config.get(config_key) is not None: + return config.get(config_key) + + return "" + + +def mask_email(email): + """Masks an email address, showing only the first and last character before the '@' and the domain.""" + if "@" not in email: + return email + + parts = email.split("@") + local_part = parts[0] + domain_part = parts[1] + + if len(local_part) <= 2: + masked_local = "*" * len(local_part) + else: + masked_local = local_part[0] + "*" * (len(local_part) - 2) + local_part[-1] + + return f"{masked_local}@{domain_part}" + + +def mask_phone_number(phone_number): + """Masks a phone number, showing only the last four digits.""" + digits_only = "".join(filter(str.isdigit, phone_number)) + + if len(digits_only) <= 4: + return "*" * len(digits_only) + else: + return "*" * (len(digits_only) - 4) + digits_only[-4:] diff --git a/frontend/src/metadata.json b/frontend/src/metadata.json index 9a9c0b534..31009d761 100644 --- a/frontend/src/metadata.json +++ b/frontend/src/metadata.json @@ -1 +1 @@ -{"buildMajor":0,"buildMinor":3,"buildPatch":7,"buildTag":""} \ No newline at end of file +{"buildMajor":0,"buildMinor":3,"buildPatch":42,"buildTag":""} \ No newline at end of file diff --git a/frontend/src/pages/app/components/SideMenu/SideMenuDropdown.jsx b/frontend/src/pages/app/components/SideMenu/SideMenuDropdown.jsx index cbdc7c99f..c72102caa 100644 --- a/frontend/src/pages/app/components/SideMenu/SideMenuDropdown.jsx +++ b/frontend/src/pages/app/components/SideMenu/SideMenuDropdown.jsx @@ -17,11 +17,25 @@ export default function SideMenuDropdown({ label, Icon, sublinks }) { const [popperElement, setPopperElement] = useState(null); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: 'right-start', + strategy: 'fixed', modifiers: [ { name: 'offset', options: { - offset: [20, 0], + offset: [0, 8], + }, + }, + { + name: 'preventOverflow', + options: { + boundary: 'viewport', + padding: 8, + }, + }, + { + name: 'flip', + options: { + fallbackPlacements: ['right-end', 'left-start', 'left-end'], }, }, ], @@ -48,12 +62,18 @@ export default function SideMenuDropdown({ label, Icon, sublinks }) { setPopperElement(ref)} - style={styles['popper']} - {...attributes['popper']} + enter="transition ease-out duration-100" + enterFrom="transform opacity-0 scale-95" + enterTo="transform opacity-100 scale-100" + leave="transition ease-in duration-75" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95" > - + setPopperElement(ref)} + style={styles['popper']} + {...attributes['popper']} + className="z-50 w-[186px] origin-top-right rounded-[4px] bg-[#E1D6AE] shadow-table-menu focus:outline-none">
{sublinks?.map(({ url, label, dataCy }) => { return ( diff --git a/frontend/src/pages/app/components/SideMenu/index.jsx b/frontend/src/pages/app/components/SideMenu/index.jsx index 0a6addfaf..1344ce6f6 100644 --- a/frontend/src/pages/app/components/SideMenu/index.jsx +++ b/frontend/src/pages/app/components/SideMenu/index.jsx @@ -12,7 +12,8 @@ export default function SideMenu() { return (
{ element={} /> } + path="/app-settings/*" + element={} /> } /> { path="/audit-logs/framework-objects-logs//*" element={} /> - } - /> } /> } /> } /> + } + /> ); diff --git a/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/DetailsTable/EachDescriptionRow.jsx b/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/DetailsTable/EachDescriptionRow.jsx new file mode 100644 index 000000000..cd7f0aeba --- /dev/null +++ b/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/DetailsTable/EachDescriptionRow.jsx @@ -0,0 +1,16 @@ +import React from "react"; + +const EachDescriptionRow = ({ label, content }) => { + return ( +
+
+ {label} +
+
+ {content || "Not configured"} +
+
+ ); +}; + +export default EachDescriptionRow; \ No newline at end of file diff --git a/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/DetailsTable/index.jsx b/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/DetailsTable/index.jsx new file mode 100644 index 000000000..6cce728b1 --- /dev/null +++ b/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/DetailsTable/index.jsx @@ -0,0 +1,208 @@ +import React from "react"; + +const DetailsTable = ({ data }) => { + if (!data) { + return ( +
+
No authentication configuration found
+
+ ); + } + + const authConfig = data.auth_config || {}; + + const StatusBadge = ({ enabled, enabledText = "Enabled", disabledText = "Disabled" }) => ( + +
+ {enabled ? enabledText : disabledText} + + ); + + const InfoCard = ({ title, icon, children }) => ( +
+
+
+ {icon} +
+

+ {title} +

+
+
+ {children} +
+
+ ); + + const InfoRow = ({ label, value, type = "text" }) => ( +
+ + {label} + +
+ {type === "badge" ? value : ( + {value} + )} +
+
+ ); + + return ( +
+ {/* Login Methods Card */} + + + + } + > + } + type="badge" + /> + {authConfig.login_methods?.password?.enabled && ( + <> + } + type="badge" + /> + + + )} + } + type="badge" + /> + } + type="badge" + /> + } + type="badge" + /> + + + + {/* Session Policy Card */} + + + + + } + > + + } + type="badge" + /> + + + {/* Password Policy Card */} + + + + + } + > + + } + type="badge" + /> + } + type="badge" + /> + } + type="badge" + /> + } + type="badge" + /> + } + type="badge" + /> + + + + + {/* Two-Factor Authentication Card */} + + + + + } + > + } + type="badge" + /> + + +
+ ); +}; + +export default DetailsTable; \ No newline at end of file diff --git a/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/UpdateAuthConfigButton.jsx b/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/UpdateAuthConfigButton.jsx new file mode 100644 index 000000000..227ad0af3 --- /dev/null +++ b/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/UpdateAuthConfigButton.jsx @@ -0,0 +1,45 @@ +import React from "react"; +import { useNavigate, useLocation } from "react-router-dom"; + +const UpdateAuthConfigButton = () => { + const navigate = useNavigate(); + const location = useLocation(); + + const handleUpdateAuthConfig = (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log("Current location:", location.pathname); + console.log("Navigating to: ./configure"); + + // Check if we're on the auth page specifically + if (location.pathname.includes('/auth')) { + navigate("./configure"); + } else { + navigate("./auth/configure"); + } + }; + + return ( + + ); +}; + +export default UpdateAuthConfigButton; \ No newline at end of file diff --git a/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/index.jsx b/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/index.jsx new file mode 100644 index 000000000..211ba43ae --- /dev/null +++ b/frontend/src/pages/appConfiguration/components/AppConfiguration/AuthConfiguration/index.jsx @@ -0,0 +1,286 @@ +import React from "react"; +import DetailsTable from "./DetailsTable"; +import UpdateAuthConfigButton from "./UpdateAuthConfigButton"; +import { useSelector } from "react-redux"; +import { selectAppConfigurationData } from '../../../slice'; + +const AuthConfiguration = () => { + const appConfigurationData = useSelector( + selectAppConfigurationData + ); + + // Show loading only when appConfigurationData is null/undefined + // Once we have appConfigurationData (even if auth_config is missing), show the page + if (!appConfigurationData) { + return ( +
+
+
+

Loading authentication configuration...

+
+
+ ); + } + + console.log("App Configuration Data:", appConfigurationData); + const auth_config = appConfigurationData?.app?.auth_config; + + // If appConfigurationData exists but auth_config is missing, show empty state + if (!auth_config) { + return ( +
+ {/* Header */} +
+
+
+
+ + + +
+
+

+ Authentication Configuration +

+

+ Manage security settings and authentication methods for your application +

+
+
+ +
+
+ + {/* Empty State */} +
+
+ + + + +
+

+ No Authentication Configuration Found +

+

+ Authentication configuration is not available. Please check your application settings. +

+
+
+ ); + } + + const ConfigCard = ({ title, description, icon, status, children }) => ( +
+
+
+
+
+ {icon} +
+
+

+ {title} +

+

+ {description} +

+
+
+ {status && ( + + {status.toUpperCase()} + + )} +
+ {children} +
+
+ ); + + const StatusBadge = ({ enabled, label }) => ( + +
+ {label} +
+ ); + + const InfoRow = ({ label, value, isArray = false }) => ( +
+ {label} +
+ {isArray ? ( +
+ {value?.map((item, index) => ( + + {item.charAt(0).toUpperCase() + item.slice(1)} + + ))} +
+ ) : ( + {value} + )} +
+
+ ); + + return ( +
+ {/* Header */} +
+
+
+
+ + + +
+
+

+ Authentication Configuration +

+

+ Manage security settings and authentication methods for your application +

+
+
+ +
+
+ + {/* Configuration Cards */} +
+ {/* Login Methods */} + + + + } + > +
+
+ + + + +
+ + +
+
+ + {/* Two-Factor Authentication */} + + + + + } + > +
+ + +
+
+ + {/* Session Policy */} + + + + + } + > +
+ + +
+
+ + {/* Password Policy */} + + + + + } + > +
+ + + + + + + +
+
+ +
+
+ ); +}; + +export default AuthConfiguration; diff --git a/frontend/src/pages/appConfiguration/components/AuthConfigurationForm/index.jsx b/frontend/src/pages/appConfiguration/components/AuthConfigurationForm/index.jsx new file mode 100644 index 000000000..771c6a04a --- /dev/null +++ b/frontend/src/pages/appConfiguration/components/AuthConfigurationForm/index.jsx @@ -0,0 +1,652 @@ +import React, { useState } from "react"; +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { Formik } from "formik"; +import * as Yup from "yup"; +import { toggleRerenderPage } from "../../slice"; +import InputField from "../../../../components/Form/InputField"; +import BreadCrumbs from "../../../app/components/BreadCrumbs"; +import { useSelector } from "react-redux"; +import { selectAppConfigurationData } from '../../slice'; +import useApi from "../../../../hooks/useApi"; +import { transformToFormData } from "../../../../utils/form"; +import { useParams } from "react-router-dom"; + +const AuthConfigurationForm = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const [isSubmitting, setIsSubmitting] = useState(false); + const triggerApi = useApi(); + + const appConfigurationData = useSelector(selectAppConfigurationData); + const auth_config = appConfigurationData?.app?.auth_config; + const { appId } = useParams(); + + const validationSchema = Yup.object({ + login_methods: Yup.object({ + password: Yup.object({ + enabled: Yup.boolean(), + forgot_password_enabled: Yup.boolean(), + password_reset_link_expiry_hours: Yup.number() + .min(1, "Must be at least 1 hour") + .max(168, "Must be less than 168 hours (7 days)"), + }), + otp: Yup.object({ + enabled: Yup.boolean(), + }), + sso: Yup.object({ + enabled: Yup.boolean(), + }), + oidc: Yup.object({ + enabled: Yup.boolean(), + }), + allowed_usernames: Yup.array().min(1, "At least one username type is required"), + }), + session_policy: Yup.object({ + max_concurrent_sessions: Yup.number().min(0, "Cannot be negative"), + force_logout_on_password_change: Yup.boolean(), + }), + password_policy: Yup.object({ + min_length: Yup.number().min(4, "Minimum length must be at least 4").max(128, "Maximum length is 128"), + allow_change: Yup.boolean(), + require_numbers: Yup.boolean(), + require_lowercase: Yup.boolean(), + require_uppercase: Yup.boolean(), + require_special_chars: Yup.boolean(), + password_expiry_days: Yup.number().min(1, "Must be at least 1 day").max(365, "Must be less than 365 days"), + password_history_count: Yup.number().min(0, "Cannot be negative").max(24, "Maximum is 24"), + }), + two_factor_auth: Yup.object({ + required: Yup.boolean(), + allowedMethods: Yup.array().min(1, "At least one method is required"), + }), + }); + + let onSubmit = (values) => { + const cleanedAuthConfig = values; + + if (Object.keys(cleanedAuthConfig).length === 0) { + console.warn('No configuration changes to save'); + return; + } + + const tempValues = { + auth_config: JSON.stringify(cleanedAuthConfig) + }; + + const dynamicFormData = transformToFormData(tempValues); + + const makeApiCall = async () => { + setIsSubmitting(true); + try { + const { response, success } = await triggerApi({ + url: `/api/v1/apps/${appId}/`, + type: 'PUT', + loader: true, + payload: dynamicFormData, + }); + + if (success && response) { + dispatch(toggleRerenderPage()); + window.location.reload(); + } + } catch (error) { + console.error('Error saving configuration:', error); + } finally { + setIsSubmitting(false); + } + }; + + makeApiCall(); + }; + + const handleCancel = () => { + navigate(-1); + }; + + const usernameOptions = [ + { id: "email", label: "Email" }, + { id: "phone", label: "Phone Number" }, + ]; + + const loginOTPMethodOptions = [ + { id: "email", label: "Email" }, + { id: "sms", label: "SMS" }, + ]; + + const twoFactorMethodOptions = [ + { id: "email", label: "Email" }, + { id: "sms", label: "SMS" }, + ]; + + const MultiSelectChips = ({ label, name, options, value, onChange, description }) => ( +
+
+ + {description && ( +

+ {description} +

+ )} +
+
+ {options.map((option) => { + const isSelected = Array.isArray(value) ? value.includes(option.id) : value === option.id; + return ( + + ); + })} +
+
+ ); + + const ToggleSwitch = ({ checked, onChange, disabled = false }) => ( + + ); + + const ToggleCard = ({ title, description, name, value, onChange, children, disabled = false }) => ( +
+
+
+
+
+

+ {title} +

+ {value && ( + + + + + ACTIVE + + )} +
+ {description && ( +

+ {description} +

+ )} +
+
+ onChange({ target: { checked } })} + disabled={disabled} + /> +
+
+ {value && children && ( +
+ {children} +
+ )} +
+
+ ); + + const [activeTab, setActiveTab] = useState("login"); + + const tabs = [ + { id: "login", label: "Login Methods", icon: "🔐" }, + { id: "session", label: "Session Policy", icon: "⏰" }, + { id: "password", label: "Password Policy", icon: "🔒" }, + { id: "2fa", label: "Two-Factor Auth", icon: "🛡️" } + ]; + + // Show loading state while data is being fetched + if (!appConfigurationData || !auth_config) { + return ( +
+
+
+

Loading configuration...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+
+
+ + + +
+
+

+ Authentication Settings +

+

+ Configure security policies and authentication methods +

+
+
+
+ + +
+
+
+ + {/* Main Content */} +
+ {/* Sidebar Tabs */} +
+
+

+ Configuration Sections +

+
+ {tabs.map((tab) => ( + + ))} +
+
+
+ + {/* Content Area */} +
+ + {(formik) => { + const { values, setFieldValue, handleSubmit, isValid, dirty } = formik; + + const renderTabContent = () => { + switch (activeTab) { + case "login": + return ( +
+
+
+
+ + + +
+
+

Login Methods

+

Configure available authentication methods

+
+
+ +
+ setFieldValue("login_methods.password.enabled", e.target.checked)} + > + +
+ setFieldValue("login_methods.password.allowed_usernames", value)} + /> +
+ +
+ + setFieldValue("login_methods.password.forgot_password_enabled", e.target.checked)} + > + setFieldValue("login_methods.password.password_reset_link_expiry_hours", parseInt(e.target.value))} + /> + + +
+
+ +
+
+ setFieldValue("login_methods.otp.enabled", e.target.checked)} + > + setFieldValue("login_methods.otp.allowed_methods", value)} + /> + + +
+ setFieldValue("login_methods.sso.enabled", e.target.checked)} + /> + setFieldValue("login_methods.oidc.enabled", e.target.checked)} + /> +
+
+
+
+ ); + + case "session": + return ( +
+
+
+
+ + + + +
+
+

Session Policy

+

Configure session management settings

+
+
+ + +
+ setFieldValue("session_policy.max_concurrent_sessions", parseInt(e.target.value) || 0)} + /> + setFieldValue("session_policy.force_logout_on_password_change", e.target.checked)} + /> +
+
+
+ ); + + case "password": + return ( +
+
+
+
+ + + + +
+
+

Password Policy

+

Define password requirements and security rules

+
+
+ + +
+
+ setFieldValue("password_policy.min_length", parseInt(e.target.value))} + /> + setFieldValue("password_policy.password_expiry_days", parseInt(e.target.value))} + /> +
+ +
+ setFieldValue("password_policy.allow_change", e.target.checked)} + /> + setFieldValue("password_policy.require_numbers", e.target.checked)} + /> + setFieldValue("password_policy.require_lowercase", e.target.checked)} + /> + setFieldValue("password_policy.require_uppercase", e.target.checked)} + /> + setFieldValue("password_policy.require_special_chars", e.target.checked)} + /> +
+ + +
+ setFieldValue("password_policy.password_history_count", parseInt(e.target.value))} + /> +
+ +
+
+
+ ); + + case "2fa": + return ( +
+
+
+
+ + + + +
+
+

Two-Factor Authentication

+

Configure additional security layer

+
+
+ +
+ setFieldValue("two_factor_auth.required", e.target.checked)} + > +
+ +
+ setFieldValue("two_factor_auth.allowed_methods", value)} + /> +
+ + +
+ setFieldValue("two_factor_auth.email_hook", e.target.value)} + /> +
+ +
+ setFieldValue("two_factor_auth.sms_hook", e.target.value)} + /> +
+
+
+
+
+
+ ); + + default: + return null; + } + }; + + return ( +
+ {renderTabContent()} +
+ ); + }} +
+
+
+
+ ); +}; + +export default AuthConfigurationForm; diff --git a/frontend/src/pages/appConfiguration/components/AuthConfigurationPage/index.jsx b/frontend/src/pages/appConfiguration/components/AuthConfigurationPage/index.jsx new file mode 100644 index 000000000..ae6924d4d --- /dev/null +++ b/frontend/src/pages/appConfiguration/components/AuthConfigurationPage/index.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import BreadCrumbs from '../../../app/components/BreadCrumbs'; +import AuthConfiguration from '../AppConfiguration/AuthConfiguration'; + +export default function AuthConfigurationPage() { + return ( +
+ {/* Header */} +
+
+
+ +
+
+ + + +
+
+

+ Authentication Configuration +

+

+ Manage login methods, session policies, and security settings for your application +

+
+
+
+
+
+ + {/* Content */} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/appConfiguration/components/Modals/UpdateAuthConfigModal/UpdateAuthConfigForm.jsx b/frontend/src/pages/appConfiguration/components/Modals/UpdateAuthConfigModal/UpdateAuthConfigForm.jsx new file mode 100644 index 000000000..54dd30bfb --- /dev/null +++ b/frontend/src/pages/appConfiguration/components/Modals/UpdateAuthConfigModal/UpdateAuthConfigForm.jsx @@ -0,0 +1,346 @@ +import React, { useState } from "react"; +import { useDispatch } from "react-redux"; +import { Formik } from "formik"; +import * as Yup from "yup"; +import { toggleRerenderPage } from "../../../slice"; +import InputField from "../../../../../components/Form/InputField"; +import SelectField from "../../../../../components/Form/SelectField"; +import CheckboxField from "../../../../../components/Form/CheckboxField"; +import SubmitButton from "../../../../../components/Form/SubmitButton"; + +const UpdateAuthConfigForm = ({ closeModal }) => { + const dispatch = useDispatch(); + const [updateAuthConfigLoading, setUpdateAuthConfigLoading] = useState(false); + + // Mock initial values - will be populated from API later + const initialValues = { + login_methods: { + password: { + enabled: true, + forgot_password_enabled: true, + password_reset_link_expiry_hours: 24, + }, + otp: { + enabled: false, + }, + sso: { + enabled: false, + }, + oidc: { + enabled: false, + }, + allowed_usernames: "email", + }, + session_policy: { + max_concurrent_sessions: 0, + force_logout_on_password_change: false, + }, + password_policy: { + min_length: 8, + allow_change: true, + require_numbers: true, + require_lowercase: true, + require_uppercase: true, + require_special_chars: true, + password_expiry_days: 90, + password_history_count: 3, + }, + two_factor_auth: { + required: true, + allowedMethods: "email", + }, + }; + + const validationSchema = Yup.object({ + login_methods: Yup.object({ + password: Yup.object({ + enabled: Yup.boolean(), + forgot_password_enabled: Yup.boolean(), + password_reset_link_expiry_hours: Yup.number() + .min(1, "Must be at least 1 hour") + .max(168, "Must be less than 168 hours (7 days)"), + }), + otp: Yup.object({ + enabled: Yup.boolean(), + }), + sso: Yup.object({ + enabled: Yup.boolean(), + }), + oidc: Yup.object({ + enabled: Yup.boolean(), + }), + allowed_usernames: Yup.string().required("Username type is required"), + }), + session_policy: Yup.object({ + max_concurrent_sessions: Yup.number().min(0, "Cannot be negative"), + force_logout_on_password_change: Yup.boolean(), + }), + password_policy: Yup.object({ + min_length: Yup.number().min(4, "Minimum length must be at least 4").max(128, "Maximum length is 128"), + allow_change: Yup.boolean(), + require_numbers: Yup.boolean(), + require_lowercase: Yup.boolean(), + require_uppercase: Yup.boolean(), + require_special_chars: Yup.boolean(), + password_expiry_days: Yup.number().min(1, "Must be at least 1 day").max(365, "Must be less than 365 days"), + password_history_count: Yup.number().min(0, "Cannot be negative").max(24, "Maximum is 24"), + }), + two_factor_auth: Yup.object({ + required: Yup.boolean(), + allowedMethods: Yup.string().required("At least one method is required"), + }), + }); + + const handleSubmit = async (values) => { + setUpdateAuthConfigLoading(true); + + // Simulate API call delay + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Log the values for now - will be replaced with actual API call later + console.log("Auth config values to be saved:", values); + + // Close modal and trigger re-render + dispatch(toggleRerenderPage()); + closeModal(); + + setUpdateAuthConfigLoading(false); + }; + + const usernameOptions = [ + { id: "email", label: "Email" }, + { id: "username", label: "Username" }, + { id: "phone", label: "Phone Number" }, + ]; + + const twoFactorMethodOptions = [ + { id: "email", label: "Email" }, + { id: "sms", label: "SMS" }, + { id: "authenticator", label: "Authenticator App" }, + ]; + + return ( +
+ + {(formik) => { + const { values, setFieldValue, handleSubmit, isValid, dirty } = formik; + return ( +
+ {/* Login Methods Section */} +
+
+
+ + + +
+

+ Login Methods +

+
+
+ setFieldValue("login_methods.password.enabled", e.target.checked)} + /> + {values.login_methods.password.enabled && ( +
+ setFieldValue("login_methods.password.forgot_password_enabled", e.target.checked)} + /> + setFieldValue("login_methods.password.password_reset_link_expiry_hours", parseInt(e.target.value))} + /> +
+ )} + setFieldValue("login_methods.otp.enabled", e.target.checked)} + /> + setFieldValue("login_methods.sso.enabled", e.target.checked)} + /> + setFieldValue("login_methods.oidc.enabled", e.target.checked)} + /> + +
+
+ + {/* Session Policy Section */} +
+
+
+ + + + +
+

+ Session Policy +

+
+
+ setFieldValue("session_policy.max_concurrent_sessions", parseInt(e.target.value) || 0)} + /> + setFieldValue("session_policy.force_logout_on_password_change", e.target.checked)} + /> +
+
+ + {/* Password Policy Section */} +
+
+
+ + + + +
+

+ Password Policy +

+
+
+ setFieldValue("password_policy.min_length", parseInt(e.target.value))} + /> + setFieldValue("password_policy.allow_change", e.target.checked)} + /> + setFieldValue("password_policy.require_numbers", e.target.checked)} + /> + setFieldValue("password_policy.require_lowercase", e.target.checked)} + /> + setFieldValue("password_policy.require_uppercase", e.target.checked)} + /> + setFieldValue("password_policy.require_special_chars", e.target.checked)} + /> + setFieldValue("password_policy.password_expiry_days", parseInt(e.target.value))} + /> + setFieldValue("password_policy.password_history_count", parseInt(e.target.value))} + /> +
+
+ + {/* Two-Factor Authentication Section */} +
+
+
+ + + + +
+

+ Two-Factor Authentication +

+
+
+ setFieldValue("two_factor_auth.required", e.target.checked)} + /> + +
+
+ + {/* Submit Button */} +
+ +
+
+ ); + }} +
+
+ ); +}; + +export default UpdateAuthConfigForm; \ No newline at end of file diff --git a/frontend/src/pages/appConfiguration/components/Modals/UpdateAuthConfigModal/index.jsx b/frontend/src/pages/appConfiguration/components/Modals/UpdateAuthConfigModal/index.jsx new file mode 100644 index 000000000..d597c77d4 --- /dev/null +++ b/frontend/src/pages/appConfiguration/components/Modals/UpdateAuthConfigModal/index.jsx @@ -0,0 +1,25 @@ +import React from "react"; +import { useDispatch, useSelector } from "react-redux"; +import Modal from "../../../../../components/Modal"; +import { selectAppConfiguration, toggleUpdateAuthConfigModal } from "../../../slice"; +import UpdateAuthConfigForm from "./UpdateAuthConfigForm"; + +const UpdateAuthConfigModal = () => { + const dispatch = useDispatch(); + const { isUpdateAuthConfigModalOpen } = useSelector(selectAppConfiguration); + + const handleClose = () => { + dispatch(toggleUpdateAuthConfigModal()); + }; + + return ( + } + /> + ); +}; + +export default UpdateAuthConfigModal; \ No newline at end of file diff --git a/frontend/src/pages/appConfiguration/routes/index.js b/frontend/src/pages/appConfiguration/routes/index.js index 1aff70ec1..0901826b3 100644 --- a/frontend/src/pages/appConfiguration/routes/index.js +++ b/frontend/src/pages/appConfiguration/routes/index.js @@ -1,10 +1,14 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import AppConfiguration from '../components/AppConfiguration'; +import AuthConfigurationPage from '../components/AuthConfigurationPage'; +import AuthConfigurationForm from '../components/AuthConfigurationForm'; export const AppConfigurationRoutes = () => { return ( } /> + } /> + } /> } /> ); diff --git a/frontend/src/pages/appConfiguration/slice/index.js b/frontend/src/pages/appConfiguration/slice/index.js index 48da4c01a..5325c56a1 100644 --- a/frontend/src/pages/appConfiguration/slice/index.js +++ b/frontend/src/pages/appConfiguration/slice/index.js @@ -4,6 +4,7 @@ export const appConfigurationSlice = createSlice({ name: 'appConfiguration', initialState: { isUpdateAppDetailsModalOpen: false, + isUpdateAuthConfigModalOpen: false, rerenderPage: false, appConfigurationData: null, }, @@ -20,6 +21,15 @@ export const appConfigurationSlice = createSlice({ setAppConfigurationData: (state, action) => { state.appConfigurationData = action.payload; }, + toggleUpdateAuthConfigModal: (state) => { + state.isUpdateAuthConfigModalOpen = !state.isUpdateAuthConfigModalOpen; + }, + openUpdateAuthConfigModal: (state) => { + state.isUpdateAuthConfigModalOpen = true; + }, + closeUpdateAuthConfigModal: (state) => { + state.isUpdateAuthConfigModalOpen = false; + }, toggleRerenderPage: (state) => { state.rerenderPage = !state.rerenderPage; }, @@ -30,6 +40,9 @@ export const { toggleIsUpdateAppDetailsModalOpen, openIsUpdateAppDetailsModalOpen, closeIsUpdateAppDetailsModalOpen, + toggleUpdateAuthConfigModal, + openUpdateAuthConfigModal, + closeUpdateAuthConfigModal, setAppConfigurationData, toggleRerenderPage, } = appConfigurationSlice.actions; @@ -40,7 +53,9 @@ export const selectIsUpdateAppDetailsModalOpen = (state) => export const selectRerenderPage = (state) => state.appConfiguration.rerenderPage; -export const selectAppConfigurationData = (state) => +export const selectAppConfigurationData = (state) => state.appConfiguration.appConfigurationData; +export const selectAppConfiguration = (state) => state.appConfiguration; + export default appConfigurationSlice.reducer; diff --git a/frontend/src/pages/appUserManagement/components/Modals/AddNewUserModal/AddNewUserForm.jsx b/frontend/src/pages/appUserManagement/components/Modals/AddNewUserModal/AddNewUserForm.jsx index 9134feff4..4f4bde48e 100644 --- a/frontend/src/pages/appUserManagement/components/Modals/AddNewUserModal/AddNewUserForm.jsx +++ b/frontend/src/pages/appUserManagement/components/Modals/AddNewUserModal/AddNewUserForm.jsx @@ -59,7 +59,7 @@ const AddNewUserForm = ({ closeModal }) => { then: Yup.string() .required('Required'), }), - password: Yup.string().required('Required'), + password: Yup.string(), roles: Yup.array().min(1, 'Minimun one is required').required('Required'), }, [ diff --git a/frontend/src/pages/appUserRoles/components/Modals/AddNewUserRolesModal/AddNewUserRolesForm.jsx b/frontend/src/pages/appUserRoles/components/Modals/AddNewUserRolesModal/AddNewUserRolesForm.jsx index f327a6ed1..f5049d087 100644 --- a/frontend/src/pages/appUserRoles/components/Modals/AddNewUserRolesModal/AddNewUserRolesForm.jsx +++ b/frontend/src/pages/appUserRoles/components/Modals/AddNewUserRolesModal/AddNewUserRolesForm.jsx @@ -5,90 +5,345 @@ import { useParams } from 'react-router-dom'; import * as Yup from 'yup'; import InputField from '../../../../../components/Form/InputField'; import MultiSelectField from '../../../../../components/Form/MultiSelectField'; +import CheckboxField from '../../../../../components/Form/CheckboxField'; import SubmitButton from '../../../../../components/Form/SubmitButton'; import useApi from '../../../../../hooks/useApi'; import { transformToFormData } from '../../../../../utils/form'; import { selectAppUserRolesData, toggleRerenderPage } from '../../../slice'; const AddNewUserRolesForm = ({ closeModal }) => { - let { appId } = useParams(); - const dispatch = useDispatch(); - const appUserRolesData = useSelector(selectAppUserRolesData); - const triggerApi = useApi(); - let initialValues = { - name: '', - policies: [], - }; + let { appId } = useParams(); + const dispatch = useDispatch(); + const appUserRolesData = useSelector(selectAppUserRolesData); + const triggerApi = useApi(); - let validationSchema = Yup.object({ - name: Yup.string().required('Required'), - }); + const twoFactorMethodOptions = [ + { id: "email", label: "Email" }, + { id: "sms", label: "SMS" }, + ]; - let onSubmit = (values) => { - let tempValues = values; + const MultiSelectChips = ({ label, name, options, value, onChange, description }) => ( +
+
+ + {description && ( +

+ {description} +

+ )} +
+
+ {options.map((option) => { + const isSelected = Array.isArray(value) ? value.includes(option.id) : value === option.id; + return ( + + ); + })} +
+
+ ); - let dynamicFormData = transformToFormData(tempValues); + const ToggleCard = ({ title, description, name, value, onChange, children }) => ( +
+
+
+ +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {value && children && ( +
+ {children} +
+ )} +
+
+ ); - const makeApiCall = async () => { - const { response, success } = await triggerApi({ - url: `/api/v1/apps/${appId}/roles/`, - type: 'POST', - loader: true, - payload: dynamicFormData, - }); + // Updated initialValues - removed defaults for two_factor_auth and password_policy + let initialValues = { + name: '', + policies: [], + two_factor_auth: { + required: false, + allowedMethods: [], + }, + password_policy: { + min_length: null, + require_numbers: false, + require_lowercase: false, + require_uppercase: false, + require_special_chars: false, + password_expiry_days: null, + password_history_count: null, + }, + }; - if (success && response) { - closeModal(); - dispatch(toggleRerenderPage()); - } - }; + // Updated validationSchema to make fields optional + let validationSchema = Yup.object({ + name: Yup.string().required('Required'), + two_factor_auth: Yup.object({ + required: Yup.boolean(), + allowedMethods: Yup.array().when('required', { + is: true, + then: (schema) => schema.min(1, 'At least one method is required'), + otherwise: (schema) => schema, + }), + }), + password_policy: Yup.object({ + min_length: Yup.number().nullable().when('$hasPasswordPolicy', { + is: true, + then: (schema) => schema.min(4, 'Minimum length must be at least 4').max(128, 'Maximum length is 128'), + otherwise: (schema) => schema, + }), + require_numbers: Yup.boolean(), + require_lowercase: Yup.boolean(), + require_uppercase: Yup.boolean(), + require_special_chars: Yup.boolean(), + password_expiry_days: Yup.number().nullable().when('$hasPasswordPolicy', { + is: true, + then: (schema) => schema.min(1, 'Must be at least 1 day').max(365, 'Must be less than 365 days'), + otherwise: (schema) => schema, + }), + password_history_count: Yup.number().nullable().when('$hasPasswordPolicy', { + is: true, + then: (schema) => schema.min(0, 'Cannot be negative').max(24, 'Maximum is 24'), + otherwise: (schema) => schema, + }), + }), + }); - makeApiCall(); - }; + // Updated onSubmit function to restructure data with auth_config + let onSubmit = (values) => { + let tempValues = { ...values }; + + // Create auth_config object from two_factor_auth and password_policy + const auth_config = {}; + + // Only include two_factor_auth if it's enabled or has custom settings + if (tempValues.two_factor_auth.required || tempValues.two_factor_auth.allowedMethods.length > 0) { + auth_config.two_factor_auth = tempValues.two_factor_auth; + } + + // Only include password_policy if any setting is modified from defaults + const hasPasswordPolicyChanges = + tempValues.password_policy.min_length !== null || + tempValues.password_policy.require_numbers || + tempValues.password_policy.require_lowercase || + tempValues.password_policy.require_uppercase || + tempValues.password_policy.require_special_chars || + tempValues.password_policy.password_expiry_days !== null || + tempValues.password_policy.password_history_count !== null; + + if (hasPasswordPolicyChanges) { + auth_config.password_policy = tempValues.password_policy; + } + + // Remove the original fields and add auth_config + delete tempValues.two_factor_auth; + delete tempValues.password_policy; + + if (Object.keys(auth_config).length > 0) { + tempValues.auth_config = auth_config; + } - return ( - - {(formik) => { - return ( -
-
- - -
-
- -
-
- ); - }} -
- ); + let dynamicFormData = transformToFormData(tempValues); + + const makeApiCall = async () => { + const { response, success } = await triggerApi({ + url: `/api/v1/apps/${appId}/roles/`, + type: 'POST', + loader: true, + payload: dynamicFormData, + }); + + if (success && response) { + closeModal(); + dispatch(toggleRerenderPage()); + } + }; + + makeApiCall(); + }; + + return ( + + {(formik) => { + return ( +
+
+ + + + {/* Two-Factor Authentication Section */} +
+

+ Two-Factor Authentication +

+ formik.setFieldValue('two_factor_auth.required', e.target.checked)} + > + formik.setFieldValue('two_factor_auth.allowedMethods', value)} + /> + +
+ + {/* Password Policy Section */} +
+

+ Password Policy +

+
+ + +
+
+ formik.setFieldValue('password_policy.require_numbers', e.target.checked)} + /> + formik.setFieldValue('password_policy.require_uppercase', e.target.checked)} + /> +
+
+ formik.setFieldValue('password_policy.require_lowercase', e.target.checked)} + /> + formik.setFieldValue('password_policy.require_special_chars', e.target.checked)} + /> +
+ +
+
+
+ +
+
+ ); + }} +
+ ); }; export default AddNewUserRolesForm; diff --git a/frontend/src/pages/appUserRoles/components/Modals/EditUserDetailsRolesModal/EditUserRolesDetailsForm.jsx b/frontend/src/pages/appUserRoles/components/Modals/EditUserDetailsRolesModal/EditUserRolesDetailsForm.jsx index a30418cae..533f21af3 100644 --- a/frontend/src/pages/appUserRoles/components/Modals/EditUserDetailsRolesModal/EditUserRolesDetailsForm.jsx +++ b/frontend/src/pages/appUserRoles/components/Modals/EditUserDetailsRolesModal/EditUserRolesDetailsForm.jsx @@ -5,104 +5,445 @@ import { useParams } from 'react-router-dom'; import * as Yup from 'yup'; import InputField from '../../../../../components/Form/InputField'; import MultiSelectField from '../../../../../components/Form/MultiSelectField'; +import CheckboxField from '../../../../../components/Form/CheckboxField'; import SubmitButton from '../../../../../components/Form/SubmitButton'; import useApi from '../../../../../hooks/useApi'; import { transformToFormData } from '../../../../../utils/form'; import { - selectAppUserRolesData, - selectAppUserRolesFormData, - toggleRerenderPage, + selectAppUserRolesData, + selectAppUserRolesFormData, + toggleRerenderPage, } from '../../../slice'; const EditUserRolesDetailsForm = ({ closeModal }) => { - let { appId } = useParams(); - const dispatch = useDispatch(); - - const appUserRolesData = useSelector(selectAppUserRolesData); - const appUserRolesFormData = useSelector(selectAppUserRolesFormData); - - const triggerApi = useApi(); - let initialValues = { - name: appUserRolesFormData?.name ?? '', - policies: - appUserRolesFormData?.attached_policies?.map((eachApp) => eachApp.id) ?? - [], - }; - - let validationSchema = Yup.object({ - name: Yup.string().required('Required'), - }); - - let onSubmit = (values) => { - let tempValues = values; - - let dynamicFormData = transformToFormData(tempValues); - - const makeApiCall = async () => { - const { response, success } = await triggerApi({ - url: `/api/v1/apps/${appId}/roles/${appUserRolesFormData?.id}/`, - type: 'PUT', - loader: true, - payload: dynamicFormData, - }); - - if (success && response) { - closeModal(); - dispatch(toggleRerenderPage()); - } - }; - - makeApiCall(); - }; - - return ( - - {(formik) => { - return ( -
-
- - - -
-
- -
-
- ); - }} -
- ); + let { appId } = useParams(); + const dispatch = useDispatch(); + + const appUserRolesData = useSelector(selectAppUserRolesData); + const appUserRolesFormData = useSelector(selectAppUserRolesFormData); + + const triggerApi = useApi(); + + // Get two-factor method options from the dropdown data or use defaults + const twoFactorMethodOptions = appUserRolesData?.dropdown_options?.two_factor_methods ?? [ + { id: "email", label: "Email" }, + { id: "sms", label: "SMS" }, + ]; + + const MultiSelectChips = ({ label, name, options, value, onChange, description }) => ( +
+
+ + {description && ( +

+ {description} +

+ )} +
+
+ {options.map((option) => { + const isSelected = Array.isArray(value) ? value.includes(option.id) : value === option.id; + return ( + + ); + })} +
+
+ ); + + const ToggleCard = ({ title, description, name, value, onChange, children }) => ( +
+
+
+ +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {value && children && ( +
+ {children} +
+ )} +
+
+ ); + + // Get default values - only include auth_config if it exists in the form data + const getDefaultValues = () => { + const existingAuthConfig = appUserRolesFormData?.auth_config || {}; + + return { + name: appUserRolesFormData?.name ?? '', + policies: appUserRolesFormData?.attached_policies?.map((eachApp) => eachApp.id) ?? [], + // Only include auth_config properties if they exist in the existing data + auth_config: { + two_factor_auth: { + required: existingAuthConfig?.two_factor_auth?.required ?? false, + allowedMethods: existingAuthConfig?.two_factor_auth?.allowedMethods ?? [], + }, + password_policy: { + min_length: existingAuthConfig?.password_policy?.min_length ?? '', + require_numbers: existingAuthConfig?.password_policy?.require_numbers ?? false, + require_lowercase: existingAuthConfig?.password_policy?.require_lowercase ?? false, + require_uppercase: existingAuthConfig?.password_policy?.require_uppercase ?? false, + require_special_chars: existingAuthConfig?.password_policy?.require_special_chars ?? false, + password_expiry_days: existingAuthConfig?.password_policy?.password_expiry_days ?? '', + password_history_count: existingAuthConfig?.password_policy?.password_history_count ?? '', + }, + }, + }; + }; + + let initialValues = getDefaultValues(); + + // Get validation constraints from appUserRolesData or use defaults + const getValidationConstraints = () => { + const constraints = appUserRolesData?.validation_constraints || {}; + + return { + password_policy: { + min_length: { + min: constraints.password_policy?.min_length?.min ?? 4, + max: constraints.password_policy?.min_length?.max ?? 128, + }, + password_expiry_days: { + min: constraints.password_policy?.password_expiry_days?.min ?? 0, + max: constraints.password_policy?.password_expiry_days?.max ?? 365, + }, + password_history_count: { + min: constraints.password_policy?.password_history_count?.min ?? 0, + max: constraints.password_policy?.password_history_count?.max ?? 24, + }, + }, + }; + }; + + const validationConstraints = getValidationConstraints(); + + let validationSchema = Yup.object({ + name: Yup.string().required('Required'), + auth_config: Yup.object({ + two_factor_auth: Yup.object({ + required: Yup.boolean(), + allowedMethods: Yup.array().when('required', { + is: true, + then: () => Yup.array().min(1, 'At least one method is required'), + otherwise: () => Yup.array(), + }), + }), + password_policy: Yup.object({ + min_length: Yup.number() + .nullable() + .transform((value, originalValue) => (originalValue === '' ? null : value)) + .when('$isSet', { + is: true, + then: () => Yup.number() + .min(validationConstraints.password_policy.min_length.min, `Minimum length must be at least ${validationConstraints.password_policy.min_length.min}`) + .max(validationConstraints.password_policy.min_length.max, `Maximum length is ${validationConstraints.password_policy.min_length.max}`), + }), + require_numbers: Yup.boolean(), + require_lowercase: Yup.boolean(), + require_uppercase: Yup.boolean(), + require_special_chars: Yup.boolean(), + password_expiry_days: Yup.number() + .nullable() + .transform((value, originalValue) => (originalValue === '' ? null : value)) + .when('$isSet', { + is: true, + then: () => Yup.number() + .min(validationConstraints.password_policy.password_expiry_days.min, `Must be at least ${validationConstraints.password_policy.password_expiry_days.min} days`) + .max(validationConstraints.password_policy.password_expiry_days.max, `Must be less than ${validationConstraints.password_policy.password_expiry_days.max} days`), + }), + password_history_count: Yup.number() + .nullable() + .transform((value, originalValue) => (originalValue === '' ? null : value)) + .when('$isSet', { + is: true, + then: () => Yup.number() + .min(validationConstraints.password_policy.password_history_count.min, 'Cannot be negative') + .max(validationConstraints.password_policy.password_history_count.max, `Maximum is ${validationConstraints.password_policy.password_history_count.max}`), + }), + }), + }), + }); + + // Helper function to remove empty/default values from auth_config + const cleanAuthConfig = (authConfig) => { + const cleanedConfig = {}; + + // Handle two_factor_auth + if (authConfig.two_factor_auth.required || authConfig.two_factor_auth.allowedMethods.length > 0) { + cleanedConfig.two_factor_auth = {}; + if (authConfig.two_factor_auth.required) { + cleanedConfig.two_factor_auth.required = true; + } + if (authConfig.two_factor_auth.allowedMethods.length > 0) { + cleanedConfig.two_factor_auth.allowedMethods = authConfig.two_factor_auth.allowedMethods; + } + } + + // Handle password_policy + const passwordPolicyConfig = {}; + let hasPasswordPolicy = false; + + if (authConfig.password_policy.min_length !== '' && authConfig.password_policy.min_length !== null) { + passwordPolicyConfig.min_length = authConfig.password_policy.min_length; + hasPasswordPolicy = true; + } + + if (authConfig.password_policy.require_numbers) { + passwordPolicyConfig.require_numbers = true; + hasPasswordPolicy = true; + } + + if (authConfig.password_policy.require_lowercase) { + passwordPolicyConfig.require_lowercase = true; + hasPasswordPolicy = true; + } + + if (authConfig.password_policy.require_uppercase) { + passwordPolicyConfig.require_uppercase = true; + hasPasswordPolicy = true; + } + + if (authConfig.password_policy.require_special_chars) { + passwordPolicyConfig.require_special_chars = true; + hasPasswordPolicy = true; + } + + if (authConfig.password_policy.password_expiry_days !== '' && authConfig.password_policy.password_expiry_days !== null) { + passwordPolicyConfig.password_expiry_days = authConfig.password_policy.password_expiry_days; + hasPasswordPolicy = true; + } + + if (authConfig.password_policy.password_history_count !== '' && authConfig.password_policy.password_history_count !== null) { + passwordPolicyConfig.password_history_count = authConfig.password_policy.password_history_count; + hasPasswordPolicy = true; + } + + if (hasPasswordPolicy) { + cleanedConfig.password_policy = passwordPolicyConfig; + } + + return cleanedConfig; + }; + + let onSubmit = (values) => { + // Clean auth_config to only include explicitly set values + const cleanedAuthConfig = cleanAuthConfig(values.auth_config); + + // Transform the values to include auth_config as JSON only if it has content + let tempValues = { + ...values, + }; + + // Only include auth_config if it has actual content + if (Object.keys(cleanedAuthConfig).length > 0) { + tempValues.auth_config = JSON.stringify(cleanedAuthConfig); + } + + let dynamicFormData = transformToFormData(tempValues); + + const makeApiCall = async () => { + const { response, success } = await triggerApi({ + url: `/api/v1/apps/${appId}/roles/${appUserRolesFormData?.id}/`, + type: 'PUT', + loader: true, + payload: dynamicFormData, + }); + + if (success && response) { + closeModal(); + dispatch(toggleRerenderPage()); + } + }; + + makeApiCall(); + }; + + return ( + + {(formik) => { + return ( +
+
+ + + + + {/* Two-Factor Authentication Section */} +
+

+ Two-Factor Authentication +

+ formik.setFieldValue('auth_config.two_factor_auth.required', e.target.checked)} + > + formik.setFieldValue('auth_config.two_factor_auth.allowedMethods', value)} + /> + +
+ + {/* Password Policy Section */} +
+

+ Password Policy +

+
+ + +
+
+ formik.setFieldValue('auth_config.password_policy.require_numbers', e.target.checked)} + /> + formik.setFieldValue('auth_config.password_policy.require_uppercase', e.target.checked)} + /> +
+
+ formik.setFieldValue('auth_config.password_policy.require_lowercase', e.target.checked)} + /> + formik.setFieldValue('auth_config.password_policy.require_special_chars', e.target.checked)} + /> +
+ +
+
+
+ +
+
+ ); + }} +
+ ); }; export default EditUserRolesDetailsForm;