Skip to content

Commit 97c8901

Browse files
feat: change user email
1 parent c04ae51 commit 97c8901

File tree

4 files changed

+1353
-3
lines changed

4 files changed

+1353
-3
lines changed

apps/api/plane/app/urls/user.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@
3030
UserEndpoint.as_view({"get": "retrieve_user_settings"}),
3131
name="users",
3232
),
33+
path(
34+
"users/me/email/generate-code/",
35+
UserEndpoint.as_view({"post": "generate_email_verification_code"}),
36+
name="user-email-verify-code",
37+
),
38+
path(
39+
"users/me/email/",
40+
UserEndpoint.as_view({"patch": "update_email"}),
41+
name="user-email-update",
42+
),
3343
# Profile
3444
path("users/me/profile/", ProfileEndpoint.as_view(), name="accounts"),
3545
# End profile

apps/api/plane/app/views/user/base.py

Lines changed: 174 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
# Python imports
22
import uuid
3+
import json
4+
import logging
35

46
# Django imports
57
from django.db.models import Case, Count, IntegerField, Q, When
68
from django.contrib.auth import logout
79
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
814

915
# Third party imports
1016
from rest_framework import status
@@ -36,9 +42,15 @@
3642
from plane.authentication.utils.host import user_ip
3743
from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
3844
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")
4254

4355

4456
class UserEndpoint(BaseViewSet):
@@ -69,6 +81,165 @@ def retrieve_instance_admin(self, request):
6981
def partial_update(self, request, *args, **kwargs):
7082
return super().partial_update(request, *args, **kwargs)
7183

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+
72243
def deactivate(self, request):
73244
# Check all workspace user is active
74245
user = self.get_object()
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Python imports
2+
import logging
3+
4+
# Third party imports
5+
from celery import shared_task
6+
7+
# Django imports
8+
from django.core.mail import EmailMultiAlternatives, get_connection
9+
from django.template.loader import render_to_string
10+
from django.utils.html import strip_tags
11+
12+
# Module imports
13+
from plane.db.models import User
14+
from plane.license.utils.instance_value import get_email_configuration
15+
from plane.utils.exception_logger import log_exception
16+
17+
18+
@shared_task
19+
def send_email_update_magic_code(email, key, token):
20+
try:
21+
(
22+
EMAIL_HOST,
23+
EMAIL_HOST_USER,
24+
EMAIL_HOST_PASSWORD,
25+
EMAIL_PORT,
26+
EMAIL_USE_TLS,
27+
EMAIL_USE_SSL,
28+
EMAIL_FROM,
29+
) = get_email_configuration()
30+
31+
# Send the mail
32+
subject = "Verify your new email address"
33+
context = {"code": token, "email": email}
34+
35+
html_content = render_to_string("emails/auth/magic_signin.html", context)
36+
text_content = strip_tags(html_content)
37+
38+
connection = get_connection(
39+
host=EMAIL_HOST,
40+
port=int(EMAIL_PORT),
41+
username=EMAIL_HOST_USER,
42+
password=EMAIL_HOST_PASSWORD,
43+
use_tls=EMAIL_USE_TLS == "1",
44+
use_ssl=EMAIL_USE_SSL == "1",
45+
)
46+
47+
msg = EmailMultiAlternatives(
48+
subject=subject,
49+
body=text_content,
50+
from_email=EMAIL_FROM,
51+
to=[email],
52+
connection=connection,
53+
)
54+
msg.attach_alternative(html_content, "text/html")
55+
msg.send()
56+
logging.getLogger("plane.worker").info("Email sent successfully.")
57+
return
58+
except Exception as e:
59+
log_exception(e)
60+
return
61+
62+
63+
@shared_task
64+
def send_email_update_confirmation(email):
65+
"""
66+
Send a confirmation email to the user after their email address has been successfully updated.
67+
68+
Args:
69+
email: The new email address that was successfully updated
70+
"""
71+
try:
72+
(
73+
EMAIL_HOST,
74+
EMAIL_HOST_USER,
75+
EMAIL_HOST_PASSWORD,
76+
EMAIL_PORT,
77+
EMAIL_USE_TLS,
78+
EMAIL_USE_SSL,
79+
EMAIL_FROM,
80+
) = get_email_configuration()
81+
82+
# Send the confirmation email
83+
subject = "Plane email address successfully updated"
84+
context = {"email": email}
85+
86+
html_content = render_to_string("emails/user/email_updated.html", context)
87+
text_content = strip_tags(html_content)
88+
89+
connection = get_connection(
90+
host=EMAIL_HOST,
91+
port=int(EMAIL_PORT),
92+
username=EMAIL_HOST_USER,
93+
password=EMAIL_HOST_PASSWORD,
94+
use_tls=EMAIL_USE_TLS == "1",
95+
use_ssl=EMAIL_USE_SSL == "1",
96+
)
97+
98+
msg = EmailMultiAlternatives(
99+
subject=subject,
100+
body=text_content,
101+
from_email=EMAIL_FROM,
102+
to=[email],
103+
connection=connection,
104+
)
105+
msg.attach_alternative(html_content, "text/html")
106+
msg.send()
107+
logging.getLogger("plane.worker").info(f"Email update confirmation sent successfully to {email}.")
108+
return
109+
except Exception as e:
110+
log_exception(e)
111+
return

0 commit comments

Comments
 (0)