|
1 | 1 | # Python imports |
2 | 2 | import uuid |
| 3 | +import json |
| 4 | +import logging |
3 | 5 |
|
4 | 6 | # Django imports |
5 | 7 | from django.db.models import Case, Count, IntegerField, Q, When |
6 | 8 | from django.contrib.auth import logout |
7 | 9 | from django.utils import timezone |
| 10 | +from django.utils.decorators import method_decorator |
| 11 | +from django.views.decorators.cache import cache_control |
| 12 | +from django.views.decorators.vary import vary_on_cookie |
| 13 | +from django.core.validators import validate_email |
8 | 14 |
|
9 | 15 | # Third party imports |
10 | 16 | from rest_framework import status |
|
36 | 42 | from plane.authentication.utils.host import user_ip |
37 | 43 | from plane.bgtasks.user_deactivation_email_task import user_deactivation_email |
38 | 44 | from plane.utils.host import base_host |
39 | | -from django.utils.decorators import method_decorator |
40 | | -from django.views.decorators.cache import cache_control |
41 | | -from django.views.decorators.vary import vary_on_cookie |
| 45 | +from plane.authentication.provider.credentials.magic_code import MagicCodeProvider |
| 46 | +from plane.authentication.adapter.error import ( |
| 47 | + AuthenticationException, |
| 48 | +) |
| 49 | +from plane.bgtasks.user_email_update_task import send_email_update_magic_code, send_email_update_confirmation |
| 50 | +from plane.settings.redis import redis_instance |
| 51 | + |
| 52 | + |
| 53 | +logger = logging.getLogger("plane") |
42 | 54 |
|
43 | 55 |
|
44 | 56 | class UserEndpoint(BaseViewSet): |
@@ -69,6 +81,165 @@ def retrieve_instance_admin(self, request): |
69 | 81 | def partial_update(self, request, *args, **kwargs): |
70 | 82 | return super().partial_update(request, *args, **kwargs) |
71 | 83 |
|
| 84 | + def generate_email_verification_code(self, request): |
| 85 | + """ |
| 86 | + Generate and send a magic code to the new email address for verification. |
| 87 | + """ |
| 88 | + user = self.get_object() |
| 89 | + new_email = request.data.get("email", "").strip().lower() |
| 90 | + |
| 91 | + if not new_email: |
| 92 | + return Response( |
| 93 | + {"error": "Email is required"}, |
| 94 | + status=status.HTTP_400_BAD_REQUEST, |
| 95 | + ) |
| 96 | + |
| 97 | + # Validate email format |
| 98 | + try: |
| 99 | + validate_email(new_email) |
| 100 | + except Exception: |
| 101 | + return Response( |
| 102 | + {"error": "Invalid email format"}, |
| 103 | + status=status.HTTP_400_BAD_REQUEST, |
| 104 | + ) |
| 105 | + |
| 106 | + # Check if email is the same as current email |
| 107 | + if new_email == user.email: |
| 108 | + return Response( |
| 109 | + {"error": "New email must be different from current email"}, |
| 110 | + status=status.HTTP_400_BAD_REQUEST, |
| 111 | + ) |
| 112 | + |
| 113 | + # Check if email already exists in the User model |
| 114 | + if User.objects.filter(email=new_email).exclude(id=user.id).exists(): |
| 115 | + return Response( |
| 116 | + {"error": "An account with this email already exists"}, |
| 117 | + status=status.HTTP_400_BAD_REQUEST, |
| 118 | + ) |
| 119 | + |
| 120 | + try: |
| 121 | + # Generate magic code for email verification |
| 122 | + # Use a special key prefix to distinguish from regular magic signin |
| 123 | + adapter = MagicCodeProvider(request=request, key=f"email_update_{new_email}") |
| 124 | + key, token = adapter.initiate() |
| 125 | + |
| 126 | + # Debug logging |
| 127 | + logger.info(f"Generated verification code - Redis key: {key}, Token: {token}") |
| 128 | + |
| 129 | + # Send magic code to the new email |
| 130 | + send_email_update_magic_code.delay(new_email, key, token) |
| 131 | + |
| 132 | + return Response( |
| 133 | + {"key": str(key), "message": "Verification code sent to email"}, |
| 134 | + status=status.HTTP_200_OK, |
| 135 | + ) |
| 136 | + except AuthenticationException as e: |
| 137 | + return Response( |
| 138 | + e.get_error_dict(), |
| 139 | + status=status.HTTP_400_BAD_REQUEST, |
| 140 | + ) |
| 141 | + |
| 142 | + def update_email(self, request): |
| 143 | + """ |
| 144 | + Verify the magic code and update the user's email address. |
| 145 | + This endpoint verifies the code and updates the existing user record |
| 146 | + without creating a new user, ensuring the user ID remains unchanged. |
| 147 | + """ |
| 148 | + user = self.get_object() |
| 149 | + new_email = request.data.get("email", "").strip().lower() |
| 150 | + code = request.data.get("code", "").strip() |
| 151 | + |
| 152 | + if not new_email: |
| 153 | + return Response( |
| 154 | + {"error": "Email is required"}, |
| 155 | + status=status.HTTP_400_BAD_REQUEST, |
| 156 | + ) |
| 157 | + |
| 158 | + if not code: |
| 159 | + return Response( |
| 160 | + {"error": "Verification code is required"}, |
| 161 | + status=status.HTTP_400_BAD_REQUEST, |
| 162 | + ) |
| 163 | + |
| 164 | + # Validate email format |
| 165 | + try: |
| 166 | + validate_email(new_email) |
| 167 | + except Exception: |
| 168 | + return Response( |
| 169 | + {"error": "Invalid email format"}, |
| 170 | + status=status.HTTP_400_BAD_REQUEST, |
| 171 | + ) |
| 172 | + |
| 173 | + # Check if email is the same as current email |
| 174 | + if new_email == user.email: |
| 175 | + return Response( |
| 176 | + {"error": "New email must be different from current email"}, |
| 177 | + status=status.HTTP_400_BAD_REQUEST, |
| 178 | + ) |
| 179 | + |
| 180 | + # Check if email already exists in the User model |
| 181 | + if User.objects.filter(email=new_email).exclude(id=user.id).exists(): |
| 182 | + return Response( |
| 183 | + {"error": "An account with this email already exists"}, |
| 184 | + status=status.HTTP_400_BAD_REQUEST, |
| 185 | + ) |
| 186 | + |
| 187 | + # Verify the magic code |
| 188 | + try: |
| 189 | + # MagicCodeProvider.initiate() prepends "magic_" to the key, so we need to match that |
| 190 | + redis_key = f"magic_email_update_{new_email}" |
| 191 | + ri = redis_instance() |
| 192 | + |
| 193 | + if not ri.exists(redis_key): |
| 194 | + logger.warning("Redis key not found: %s. Code may have expired or was never generated.", redis_key) |
| 195 | + return Response( |
| 196 | + {"error": "Verification code has expired or is invalid"}, |
| 197 | + status=status.HTTP_400_BAD_REQUEST, |
| 198 | + ) |
| 199 | + |
| 200 | + data = json.loads(ri.get(redis_key)) |
| 201 | + stored_token = data.get("token") |
| 202 | + |
| 203 | + if str(stored_token) != str(code): |
| 204 | + logger.warning(f"Token mismatch! Provided: '{code}', Expected: '{stored_token}'") |
| 205 | + return Response( |
| 206 | + {"error": "Invalid verification code"}, |
| 207 | + status=status.HTTP_400_BAD_REQUEST, |
| 208 | + ) |
| 209 | + |
| 210 | + # Code is valid, delete it from redis |
| 211 | + ri.delete(redis_key) |
| 212 | + |
| 213 | + except Exception as e: |
| 214 | + logger.error(f"Exception during verification: {type(e).__name__}: {str(e)}", exc_info=True) |
| 215 | + return Response( |
| 216 | + {"error": "Failed to verify code. Please try again."}, |
| 217 | + status=status.HTTP_400_BAD_REQUEST, |
| 218 | + ) |
| 219 | + |
| 220 | + # Final check: ensure email is still available (might have been taken between code generation and update) |
| 221 | + if User.objects.filter(email=new_email).exclude(id=user.id).exists(): |
| 222 | + return Response( |
| 223 | + {"error": "An account with this email already exists"}, |
| 224 | + status=status.HTTP_400_BAD_REQUEST, |
| 225 | + ) |
| 226 | + |
| 227 | + # Update the email - this updates the existing user record without creating a new user |
| 228 | + user.email = new_email |
| 229 | + # Reset email verification status when email is changed |
| 230 | + user.is_email_verified = False |
| 231 | + user.save() |
| 232 | + |
| 233 | + # Logout the user |
| 234 | + logout(request) |
| 235 | + |
| 236 | + # Send confirmation email to the new email address |
| 237 | + send_email_update_confirmation.delay(new_email) |
| 238 | + |
| 239 | + # Return updated user data |
| 240 | + serialized_data = UserMeSerializer(user).data |
| 241 | + return Response(serialized_data, status=status.HTTP_200_OK) |
| 242 | + |
72 | 243 | def deactivate(self, request): |
73 | 244 | # Check all workspace user is active |
74 | 245 | user = self.get_object() |
|
0 commit comments