Skip to content

Commit 1cef698

Browse files
Merge remote-tracking branch 'origin/main' into modal
2 parents da704c8 + 2b086d0 commit 1cef698

11 files changed

Lines changed: 437 additions & 234 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "0.1.16"
3+
version = "0.1.17"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false

routers/core/account.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,10 @@ async def add_email(
743743
message = "Verification email sent. Check your inbox." if sent else "A verification email was already sent. Please check your inbox."
744744

745745
if is_htmx_request(request):
746-
return toast_response(request, templates, message, level="success")
746+
return toast_response(
747+
request, templates, message, level="success",
748+
headers={"HX-Trigger": "addEmailFormReset"},
749+
)
747750
profile_path: URLPath = user_router.url_path_for("read_profile")
748751
response = RedirectResponse(url=str(profile_path), status_code=303)
749752
set_flash_cookie(response, message)
@@ -753,11 +756,14 @@ async def add_email(
753756
@router.get("/emails/verify")
754757
async def verify_email(
755758
token: str,
756-
tokens: tuple[Optional[str], Optional[str]] = Depends(oauth2_scheme_cookie),
757759
session: Session = Depends(get_session),
758760
):
759761
"""
760762
Verify a new email address using the token from the verification link.
763+
764+
Always redirects to the login page because verification links are clicked
765+
from an email client (cross-site navigation), so samesite=strict auth
766+
cookies are never sent — even when the user has an active session.
761767
"""
762768
account, verification_token = get_account_from_email_verification_token(token, session)
763769

@@ -791,22 +797,9 @@ async def verify_email(
791797
# Send notification to primary email
792798
send_email_verified_notification(account.email, verification_token.new_email)
793799

794-
message = "Email address verified and added to your account."
795-
796-
# Lightweight auth check: just validate the access token to decide redirect target.
797-
# We avoid get_optional_user here because it triggers NeedsNewTokens (token rotation)
798-
# which would interrupt the verification flow before the route body runs.
799-
access_token, _ = tokens
800-
is_authenticated = access_token and validate_token(access_token, token_type="access") is not None
801-
802-
if is_authenticated:
803-
profile_path: URLPath = user_router.url_path_for("read_profile")
804-
response = RedirectResponse(url=str(profile_path), status_code=303)
805-
else:
806-
login_path: URLPath = router.url_path_for("read_login")
807-
response = RedirectResponse(url=str(login_path), status_code=303)
808-
809-
set_flash_cookie(response, message)
800+
login_path: URLPath = router.url_path_for("read_login")
801+
response = RedirectResponse(url=str(login_path), status_code=303)
802+
set_flash_cookie(response, "Email address verified and added to your account.")
810803
return response
811804

812805

routers/core/user.py

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,14 @@ async def read_profile(
5454
):
5555
# Load account emails
5656
account_emails = session.exec(
57-
select(AccountEmail).where(AccountEmail.account_id == user.account_id)
57+
select(AccountEmail)
58+
.where(AccountEmail.account_id == user.account_id)
59+
.order_by(AccountEmail.is_primary.desc()) # type: ignore[union-attr]
5860
).all() if user.account_id else []
5961

6062
return templates.TemplateResponse(
6163
request,
6264
"users/profile.html", {
63-
"max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024), # Convert bytes to MB
64-
"min_dimension": MIN_DIMENSION,
65-
"max_dimension": MAX_DIMENSION,
66-
"allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()),
6765
"show_form": show_form == "true",
6866
"user": user,
6967
"account_emails": account_emails,
@@ -72,6 +70,40 @@ async def read_profile(
7270
)
7371

7472

73+
@router.get("/edit-form")
74+
async def edit_profile_form(
75+
request: Request,
76+
user: User = Depends(get_authenticated_user),
77+
):
78+
if not is_htmx_request(request):
79+
return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303)
80+
return templates.TemplateResponse(
81+
request,
82+
"users/partials/profile_form.html",
83+
{
84+
"user": user,
85+
"max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024),
86+
"min_dimension": MIN_DIMENSION,
87+
"max_dimension": MAX_DIMENSION,
88+
"allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()),
89+
},
90+
)
91+
92+
93+
@router.get("/profile-display")
94+
async def profile_display(
95+
request: Request,
96+
user: User = Depends(get_authenticated_user),
97+
):
98+
if not is_htmx_request(request):
99+
return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303)
100+
return templates.TemplateResponse(
101+
request,
102+
"users/partials/profile_display.html",
103+
{"user": user},
104+
)
105+
106+
75107
@router.post("/update", response_class=RedirectResponse)
76108
async def update_profile(
77109
request: Request,
@@ -80,8 +112,10 @@ async def update_profile(
80112
user: User = Depends(get_authenticated_user),
81113
session: Session = Depends(get_session)
82114
):
115+
avatar_changed = bool(avatar_file and avatar_file.filename)
116+
83117
# Handle avatar update
84-
if avatar_file and avatar_file.filename:
118+
if avatar_changed:
85119
avatar_data = await avatar_file.read()
86120
avatar_content_type = avatar_file.content_type
87121

@@ -107,18 +141,17 @@ async def update_profile(
107141
session.refresh(user)
108142

109143
if is_htmx_request(request):
144+
if avatar_changed:
145+
# Avatar affects the navbar, which is outside the swap target.
146+
# Tell HTMX to do a full page refresh so everything updates.
147+
response = Response(status_code=200)
148+
response.headers["HX-Refresh"] = "true"
149+
return response
110150
response = templates.TemplateResponse(
111151
request,
112152
"users/partials/profile_display.html",
113-
{
114-
"user": user,
115-
"max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024),
116-
"min_dimension": MIN_DIMENSION,
117-
"max_dimension": MAX_DIMENSION,
118-
"allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()),
119-
},
153+
{"user": user},
120154
)
121-
response.headers["HX-Trigger"] = "profileUpdated"
122155
return append_toast(response, request, templates, "Profile updated successfully.")
123156
return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303)
124157

templates/base/partials/header.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
3838
<button class="profile-button btn p-0 border-0 bg-transparent">
3939
{% if user.avatar %}
40-
<img src="{{ url_for('get_avatar') }}" alt="User Avatar" class="d-inline-block align-top" width="30" height="30" style="border-radius: 50%;">
40+
<img src="{{ url_for('get_avatar') }}?v={{ range(1000000)|random }}" alt="User Avatar" class="d-inline-block align-top" width="30" height="30" style="border-radius: 50%;">
4141
{% else %}
4242
{{ render_silhouette() }}
4343
{% endif %}
Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
{# Partial: profile display card body. Swapped into #profile-display. #}
1+
{# Partial: profile display card. Swapped into #profile-card. #}
22
{% from 'base/macros/silhouette.html' import render_silhouette %}
3-
<p><strong>Name:</strong> {{ user.name }}</p>
4-
<p><strong>Email:</strong> {{ user.account.email }}</p>
5-
<div class="mb-3">
6-
{% if user.avatar %}
7-
<img src="{{ url_for('get_avatar') }}" alt="User Avatar" class="img-thumbnail" width="150">
8-
{% else %}
9-
{{ render_silhouette(width=150, height=150) }}
10-
{% endif %}
3+
<div class="card-header">
4+
Basic Information
115
</div>
12-
<button class="btn btn-primary mt-3" onclick="toggleEditProfile()">Edit</button>
13-
14-
<div id="profile-form" hx-swap-oob="innerHTML">
15-
{% include 'users/partials/profile_form.html' %}
6+
<div class="card-body">
7+
<p><strong>Name:</strong> {{ user.name }}</p>
8+
<p><strong>Email:</strong> {{ user.account.email }}</p>
9+
<div class="mb-3">
10+
{% if user.avatar %}
11+
<img src="{{ url_for('get_avatar') }}?v={{ range(1000000)|random }}" alt="User Avatar" class="img-thumbnail" width="150">
12+
{% else %}
13+
{{ render_silhouette(width=150, height=150) }}
14+
{% endif %}
15+
</div>
16+
<button class="btn btn-primary mt-3"
17+
hx-get="{{ url_for('edit_profile_form') }}"
18+
hx-target="#profile-card"
19+
hx-swap="innerHTML">Edit</button>
1620
</div>
Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,79 @@
1-
{# Partial: profile edit form card body. Swapped into #profile-form. #}
2-
<form action="{{ url_for('update_profile') }}" method="post" enctype="multipart/form-data"
3-
hx-post="{{ url_for('update_profile') }}"
4-
hx-target="#profile-display"
5-
hx-swap="innerHTML"
6-
hx-encoding="multipart/form-data">
7-
<div class="mb-3">
8-
<label for="name" class="form-label">Name</label>
9-
<input type="text" class="form-control" id="name" name="name" value="{{ user.name }}">
10-
</div>
11-
<div class="mb-3">
12-
<label for="avatar_file" class="form-label">Avatar</label>
13-
<input type="file" class="form-control" id="avatar_file" name="avatar_file" accept="image/*">
14-
<div class="form-text">
15-
<ul class="mb-0">
16-
<li>Maximum file size: {{ max_file_size_mb }} MB</li>
17-
<li>Minimum dimension: {{ min_dimension }}x{{ min_dimension }} pixels</li>
18-
<li>Maximum dimension: {{ max_dimension }}x{{ max_dimension }} pixels</li>
19-
<li>Allowed formats: {{ allowed_formats|join(', ') }}</li>
20-
<li>Image will be cropped to a square</li>
21-
</ul>
1+
{# Partial: profile edit form card. Swapped into #profile-card. #}
2+
<div class="card-header">
3+
Edit Profile
4+
</div>
5+
<div class="card-body">
6+
<form action="{{ url_for('update_profile') }}" method="post" enctype="multipart/form-data"
7+
hx-post="{{ url_for('update_profile') }}"
8+
hx-target="#profile-card"
9+
hx-swap="innerHTML"
10+
hx-encoding="multipart/form-data">
11+
<div class="mb-3">
12+
<label for="name" class="form-label">Name</label>
13+
<input type="text" class="form-control" id="name" name="name" value="{{ user.name }}">
2214
</div>
23-
</div>
24-
<button type="submit" class="btn btn-primary">Save Changes</button>
25-
</form>
15+
<div class="mb-3">
16+
<label for="avatar_file" class="form-label">Avatar</label>
17+
<input type="file" class="form-control" id="avatar_file" name="avatar_file" accept="image/*"
18+
data-max-size-mb="{{ max_file_size_mb }}"
19+
data-min-dim="{{ min_dimension }}"
20+
data-max-dim="{{ max_dimension }}"
21+
data-allowed-formats="{{ allowed_formats|join(',') }}">
22+
<div class="form-text">
23+
<ul class="mb-0">
24+
<li>Maximum file size: {{ max_file_size_mb }} MB</li>
25+
<li>Minimum dimension: {{ min_dimension }}x{{ min_dimension }} pixels</li>
26+
<li>Maximum dimension: {{ max_dimension }}x{{ max_dimension }} pixels</li>
27+
<li>Allowed formats: {{ allowed_formats|join(', ') }}</li>
28+
<li>Image will be cropped to a square</li>
29+
</ul>
30+
</div>
31+
</div>
32+
<button type="submit" class="btn btn-primary">Save Changes</button>
33+
<button type="button" class="btn btn-secondary ms-2"
34+
hx-get="{{ url_for('profile_display') }}"
35+
hx-target="#profile-card"
36+
hx-swap="innerHTML">Cancel</button>
37+
</form>
38+
</div>
39+
<script>
40+
document.getElementById('avatar_file').addEventListener('change', function(e) {
41+
var file = e.target.files[0];
42+
if (!file) return;
43+
44+
var maxSizeMB = parseFloat(this.dataset.maxSizeMb);
45+
var minDim = parseInt(this.dataset.minDim);
46+
var maxDim = parseInt(this.dataset.maxDim);
47+
var allowedFormats = this.dataset.allowedFormats.split(',');
48+
49+
var fileSizeMB = file.size / (1024 * 1024);
50+
if (fileSizeMB > maxSizeMB) {
51+
showToast('File size must be less than ' + maxSizeMB + 'MB', 'danger');
52+
this.value = '';
53+
return;
54+
}
55+
56+
if (allowedFormats.indexOf(file.type) === -1) {
57+
showToast('File format must be one of: ' + allowedFormats.join(', '), 'danger');
58+
this.value = '';
59+
return;
60+
}
61+
62+
var input = this;
63+
var img = new Image();
64+
img.src = URL.createObjectURL(file);
65+
img.onload = function() {
66+
URL.revokeObjectURL(this.src);
67+
if (this.width < minDim || this.height < minDim) {
68+
showToast('Image dimensions must be at least ' + minDim + 'x' + minDim + ' pixels', 'danger');
69+
input.value = '';
70+
return;
71+
}
72+
if (this.width > maxDim || this.height > maxDim) {
73+
showToast('Image dimensions must not exceed ' + maxDim + 'x' + maxDim + ' pixels', 'danger');
74+
input.value = '';
75+
return;
76+
}
77+
};
78+
});
79+
</script>

0 commit comments

Comments
 (0)