Skip to content

Commit 6d7f509

Browse files
Merge pull request #78 from Promptly-Technologies-LLC/14-move-update-email-to-its-own-section-of-the-profile-page-and-require-email-confirmation
Email address update confirmation flow
2 parents 59d3958 + 52dbf8d commit 6d7f509

File tree

11 files changed

+577
-58
lines changed

11 files changed

+577
-58
lines changed

main.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ async def lifespan(app: FastAPI):
3333
# Optional shutdown logic
3434

3535

36-
app = FastAPI(lifespan=lifespan)
36+
app: FastAPI = FastAPI(lifespan=lifespan)
3737

3838
# Mount static files (e.g., CSS, JS)
3939
app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -169,10 +169,12 @@ async def read_home(
169169

170170
@app.get("/login")
171171
async def read_login(
172-
params: dict = Depends(common_unauthenticated_parameters)
172+
params: dict = Depends(common_unauthenticated_parameters),
173+
email_updated: Optional[str] = "false"
173174
):
174175
if params["user"]:
175176
return RedirectResponse(url="/dashboard", status_code=302)
177+
params["email_updated"] = email_updated
176178
return templates.TemplateResponse(params["request"], "authentication/login.html", params)
177179

178180

@@ -256,14 +258,18 @@ async def read_dashboard(
256258

257259
@app.get("/profile")
258260
async def read_profile(
259-
params: dict = Depends(common_authenticated_parameters)
261+
params: dict = Depends(common_authenticated_parameters),
262+
email_update_requested: Optional[str] = "false",
263+
email_updated: Optional[str] = "false"
260264
):
261265
# Add image constraints to the template context
262266
params.update({
263267
"max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024), # Convert bytes to MB
264268
"min_dimension": MIN_DIMENSION,
265269
"max_dimension": MAX_DIMENSION,
266-
"allowed_formats": list(ALLOWED_CONTENT_TYPES.keys())
270+
"allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()),
271+
"email_update_requested": email_update_requested,
272+
"email_updated": email_updated
267273
})
268274
return templates.TemplateResponse(params["request"], "users/profile.html", params)
269275

routers/authentication.py

Lines changed: 145 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi.responses import RedirectResponse
77
from pydantic import BaseModel, EmailStr, ConfigDict
88
from sqlmodel import Session, select
9-
from utils.models import User, UserPassword
9+
from utils.models import User, UserPassword, DataIntegrityError
1010
from utils.auth import (
1111
get_session,
1212
get_user_from_reset_token,
@@ -18,13 +18,50 @@
1818
create_access_token,
1919
create_refresh_token,
2020
validate_token,
21-
send_reset_email
21+
send_reset_email,
22+
send_email_update_confirmation,
23+
get_user_from_email_update_token,
24+
get_authenticated_user
2225
)
2326

2427
logger = getLogger("uvicorn.error")
2528

2629
router = APIRouter(prefix="/auth", tags=["auth"])
2730

31+
# --- Custom Exceptions ---
32+
33+
34+
class EmailAlreadyRegisteredError(HTTPException):
35+
def __init__(self):
36+
super().__init__(
37+
status_code=409,
38+
detail="This email is already registered"
39+
)
40+
41+
42+
class InvalidCredentialsError(HTTPException):
43+
def __init__(self):
44+
super().__init__(
45+
status_code=401,
46+
detail="Invalid credentials"
47+
)
48+
49+
50+
class InvalidResetTokenError(HTTPException):
51+
def __init__(self):
52+
super().__init__(
53+
status_code=401,
54+
detail="Invalid or expired password reset token; please request a new one"
55+
)
56+
57+
58+
class InvalidEmailUpdateTokenError(HTTPException):
59+
def __init__(self):
60+
super().__init__(
61+
status_code=401,
62+
detail="Invalid or expired email update token; please request a new one"
63+
)
64+
2865

2966
# --- Server Request and Response Models ---
3067

@@ -102,6 +139,17 @@ async def as_form(
102139
new_password=new_password, confirm_new_password=confirm_new_password)
103140

104141

142+
class UpdateEmail(BaseModel):
143+
new_email: EmailStr
144+
145+
@classmethod
146+
async def as_form(
147+
cls,
148+
new_email: EmailStr = Form(...)
149+
):
150+
return cls(new_email=new_email)
151+
152+
105153
# --- DB Request and Response Models ---
106154

107155

@@ -130,7 +178,7 @@ async def register(
130178
User.email == user.email)).first()
131179

132180
if db_user:
133-
raise HTTPException(status_code=400, detail="Email already registered")
181+
raise EmailAlreadyRegisteredError()
134182

135183
# Hash the password
136184
hashed_password = get_password_hash(user.password)
@@ -147,9 +195,20 @@ async def register(
147195
refresh_token = create_refresh_token(data={"sub": db_user.email})
148196
# Set cookie
149197
response = RedirectResponse(url="/", status_code=303)
150-
response.set_cookie(key="access_token", value=access_token, httponly=True)
151-
response.set_cookie(key="refresh_token",
152-
value=refresh_token, httponly=True)
198+
response.set_cookie(
199+
key="access_token",
200+
value=access_token,
201+
httponly=True,
202+
secure=True,
203+
samesite="strict"
204+
)
205+
response.set_cookie(
206+
key="refresh_token",
207+
value=refresh_token,
208+
httponly=True,
209+
secure=True,
210+
samesite="strict"
211+
)
153212

154213
return response
155214

@@ -164,7 +223,7 @@ async def login(
164223
User.email == user.email)).first()
165224

166225
if not db_user or not db_user.password or not verify_password(user.password, db_user.password.hashed_password):
167-
raise HTTPException(status_code=400, detail="Invalid credentials")
226+
raise InvalidCredentialsError()
168227

169228
# Create access token
170229
access_token = create_access_token(
@@ -262,7 +321,7 @@ async def reset_password(
262321
user.email, user.token, session)
263322

264323
if not authorized_user or not reset_token:
265-
raise HTTPException(status_code=400, detail="Invalid or expired token")
324+
raise InvalidResetTokenError()
266325

267326
# Update password and mark token as used
268327
if authorized_user.password:
@@ -289,3 +348,81 @@ def logout():
289348
response.delete_cookie("access_token")
290349
response.delete_cookie("refresh_token")
291350
return response
351+
352+
353+
@router.post("/update_email")
354+
async def request_email_update(
355+
update: UpdateEmail = Depends(UpdateEmail.as_form),
356+
user: User = Depends(get_authenticated_user),
357+
session: Session = Depends(get_session)
358+
):
359+
# Check if the new email is already registered
360+
existing_user = session.exec(
361+
select(User).where(User.email == update.new_email)
362+
).first()
363+
364+
if existing_user:
365+
raise EmailAlreadyRegisteredError()
366+
367+
if not user.id:
368+
raise DataIntegrityError(resource="User id")
369+
370+
# Send confirmation email
371+
send_email_update_confirmation(
372+
current_email=user.email,
373+
new_email=update.new_email,
374+
user_id=user.id,
375+
session=session
376+
)
377+
378+
return RedirectResponse(
379+
url="/profile?email_update_requested=true",
380+
status_code=303
381+
)
382+
383+
384+
@router.get("/confirm_email_update")
385+
async def confirm_email_update(
386+
user_id: int,
387+
token: str,
388+
new_email: str,
389+
session: Session = Depends(get_session)
390+
):
391+
user, update_token = get_user_from_email_update_token(
392+
user_id, token, session
393+
)
394+
395+
if not user or not update_token:
396+
raise InvalidResetTokenError()
397+
398+
# Update email and mark token as used
399+
user.email = new_email
400+
update_token.used = True
401+
session.commit()
402+
403+
# Create new tokens with the updated email
404+
access_token = create_access_token(data={"sub": new_email, "fresh": True})
405+
refresh_token = create_refresh_token(data={"sub": new_email})
406+
407+
# Set cookies before redirecting
408+
response = RedirectResponse(
409+
url="/profile?email_updated=true",
410+
status_code=303
411+
)
412+
413+
# Add secure cookie attributes
414+
response.set_cookie(
415+
key="access_token",
416+
value=access_token,
417+
httponly=True,
418+
secure=True,
419+
samesite="lax"
420+
)
421+
response.set_cookie(
422+
key="refresh_token",
423+
value=refresh_token,
424+
httponly=True,
425+
secure=True,
426+
samesite="lax"
427+
)
428+
return response

routers/organization.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
logger = getLogger("uvicorn.error")
1212

13+
router = APIRouter(prefix="/organizations", tags=["organizations"])
14+
1315
# --- Custom Exceptions ---
1416

1517

@@ -37,9 +39,6 @@ def __init__(self):
3739
)
3840

3941

40-
router = APIRouter(prefix="/organizations", tags=["organizations"])
41-
42-
4342
# --- Server Request and Response Models ---
4443

4544

routers/user.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,13 @@
1616
class UpdateProfile(BaseModel):
1717
"""Request model for updating user profile information"""
1818
name: str
19-
email: EmailStr
2019
avatar_file: Optional[bytes] = None
2120
avatar_content_type: Optional[str] = None
2221

2322
@classmethod
2423
async def as_form(
2524
cls,
2625
name: str = Form(...),
27-
email: EmailStr = Form(...),
2826
avatar_file: Optional[UploadFile] = File(None),
2927
):
3028
avatar_data = None
@@ -36,7 +34,6 @@ async def as_form(
3634

3735
return cls(
3836
name=name,
39-
email=email,
4037
avatar_file=avatar_data,
4138
avatar_content_type=avatar_content_type
4239
)
@@ -73,7 +70,6 @@ async def update_profile(
7370

7471
# Update user details
7572
user.name = user_profile.name
76-
user.email = user_profile.email
7773

7874
if user_profile.avatar_file:
7975
user.avatar_data = user_profile.avatar_file
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{% extends "emails/base_email.html" %}
2+
3+
{% block email_title %}Email Update Request{% endblock %}
4+
5+
{% block email_content %}
6+
<h1>Confirm Your New Email Address</h1>
7+
<p>You have requested to change your email address from {{ current_email }} to {{ new_email }}.</p>
8+
<p>Click the link below to confirm this change:</p>
9+
<p><a href="{{ confirmation_url }}">Confirm Email Update</a></p>
10+
<p>If you did not request this change, please ignore this email.</p>
11+
<p>This link will expire in 1 hour.</p>
12+
{% endblock %}

templates/users/profile.html

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@
88
<div class="container mt-5">
99
<h1 class="mb-4">User Profile</h1>
1010

11+
{% if email_update_requested == "true" %}
12+
<div class="alert alert-info" role="alert">
13+
Please check your current email address for a confirmation link to complete the email update.
14+
</div>
15+
{% endif %}
16+
17+
{% if email_updated == "true" %}
18+
<div class="alert alert-success" role="alert">
19+
Your email address has been successfully updated.
20+
</div>
21+
{% endif %}
22+
1123
<!-- Basic Information -->
1224
<div class="card mb-4" id="basic-info">
1325
<div class="card-header">
@@ -40,10 +52,6 @@ <h1 class="mb-4">User Profile</h1>
4052
<label for="name" class="form-label">Name</label>
4153
<input type="text" class="form-control" id="name" name="name" value="{{ user.name }}">
4254
</div>
43-
<div class="mb-3">
44-
<label for="email" class="form-label">Email</label>
45-
<input type="email" class="form-control" id="email" name="email" value="{{ user.email }}">
46-
</div>
4755
<div class="mb-3">
4856
<label for="avatar_file" class="form-label">Avatar</label>
4957
<input type="file" class="form-control" id="avatar_file" name="avatar_file" accept="image/*">
@@ -62,13 +70,29 @@ <h1 class="mb-4">User Profile</h1>
6270
</div>
6371
</div>
6472

73+
<!-- New Email Update Section -->
74+
<div class="card mb-4">
75+
<div class="card-header">
76+
Update Email
77+
</div>
78+
<div class="card-body">
79+
<form action="{{ url_for('request_email_update') }}" method="post">
80+
<div class="mb-3">
81+
<label for="new_email" class="form-label">New Email Address</label>
82+
<input type="email" class="form-control" id="new_email" name="new_email" value="{{ user.email }}">
83+
</div>
84+
<p class="form-text">A confirmation link will be sent to your new email address to verify the change.</p>
85+
<button type="submit" class="btn btn-primary">Update Email</button>
86+
</form>
87+
</div>
88+
</div>
89+
6590
<!-- Change Password -->
6691
<div class="card mb-4">
6792
<div class="card-header">
6893
Change Password
6994
</div>
7095
<div class="card-body">
71-
<!-- TODO: Trigger password reset via email confirmation -->
7296
<form action="{{ url_for('forgot_password') }}" method="post">
7397
<input type="hidden" name="email" value="{{ user.email }}">
7498
<p>To change your password, please confirm your email. A password reset link will be sent to your email address.</p>
@@ -90,7 +114,7 @@ <h1 class="mb-4">User Profile</h1>
90114
<p class="text-danger">This action cannot be undone. Please confirm your password to delete your account.</p>
91115
<div class="mb-3">
92116
<label for="confirm_delete_password" class="form-label">Password</label>
93-
<input type="password" class="form-control" id="confirm_delete_password" name="confirm_delete_password">
117+
<input type="password" class="form-control" id="confirm_delete_password" name="confirm_delete_password" autocomplete="off">
94118
</div>
95119
<button type="submit" class="btn btn-danger">Delete Account</button>
96120
</form>

0 commit comments

Comments
 (0)