Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,69 @@ def clean_new_username(self):

raw = self.cleaned_data.get("new_username", "").strip()
return validate_username_format(raw, current_user=self.user)


class EmailChangeRequestForm(forms.Form):
"""Form to initiate email address change with password confirmation."""

new_email = forms.EmailField(
required=True,
widget=forms.EmailInput(
attrs={
"class": "form-input",
"placeholder": "new.email@example.com",
"autocomplete": "email",
"id": "settings-new-email",
}
),
)
current_password = forms.CharField(
required=True,
widget=forms.PasswordInput(
attrs={
"class": "form-input",
"placeholder": "Enter current password to authorize",
"autocomplete": "current-password",
"id": "settings-email-password",
}
),
)

def __init__(self, user, *args, **kwargs):
self.user = user
super().__init__(*args, **kwargs)

def clean_new_email(self):
raw = self.cleaned_data.get("new_email", "").strip().lower()
current_email = (self.user.email or "").strip().lower()
if raw == current_email:
raise ValidationError("The new email address cannot be the same as your current address.")
return raw


class EmailChangeConfirmForm(forms.Form):
"""Form to confirm 6-digit OTP code sent to the new email address."""

code = forms.CharField(
max_length=6,
min_length=6,
required=True,
widget=forms.TextInput(
attrs={
"class": "form-input font-mono-data",
"placeholder": "123456",
"id": "email-otp-combined-input",
"autocomplete": "one-time-code",
"inputmode": "numeric",
"pattern": "[0-9]{6}",
"maxlength": "6",
"style": "letter-spacing: 0.25em; text-align: center; font-size: 1.15rem; max-width: 200px;",
}
),
)

def clean_code(self):
raw = self.cleaned_data.get("code", "").strip()
if not raw.isdigit() or len(raw) != 6:
raise ValidationError("Please enter a valid 6-digit numerical code.")
return raw
12 changes: 12 additions & 0 deletions accounts/services/email_change_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,15 @@ def confirm_email_change(
)

return True, "Your email address has been successfully updated and verified."


def cancel_email_change(user, request=None) -> None:
"""
Cancels any active pending email change request and invalidates unused change_email codes.
"""
PendingEmailChange.objects.filter(user=user).delete()
EmailVerificationCode.objects.filter(
user=user,
purpose=EmailVerificationCode.PURPOSE_CHANGE_EMAIL,
is_used=False,
).update(is_used=True)
115 changes: 115 additions & 0 deletions accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@
ProfileForm,
AvatarUploadForm,
UsernameChangeForm,
EmailChangeRequestForm,
EmailChangeConfirmForm,
)
from .models import (
UserProfile,
EmailVerificationCode,
PasswordResetToken,
PendingEmailChange,
LoginLockout,
AccountEvent,
)
Expand All @@ -42,6 +45,13 @@
)
from .services.avatar_service import process_and_save_avatar, remove_avatar
from .services.username_service import change_username, get_username_cooldown_status
from .services.email_change_service import (
request_email_change,
confirm_email_change,
cancel_email_change,
)
from .services.otp_service import issue_otp
from .services.email_service import send_templated_email
from documents.models import Document
from sharing.models import SharePass
from audit.models import get_client_ip
Expand Down Expand Up @@ -523,17 +533,122 @@ def account_settings_view(request):
messages.error(request, "Could not update username. Please review the form.")
return redirect(reverse("account_settings") + "#username")

elif action == "request_email_change":
email_request_form = EmailChangeRequestForm(user, request.POST)
if email_request_form.is_valid():
try:
success, msg = request_email_change(
user=user,
new_email=email_request_form.cleaned_data["new_email"],
current_password=email_request_form.cleaned_data["current_password"],
request=request,
)
if success:
messages.success(request, msg)
else:
messages.error(request, msg)
return redirect(reverse("account_settings") + "#email")
except ValidationError as exc:
messages.error(request, exc.message if hasattr(exc, "message") else str(exc))
return redirect(reverse("account_settings") + "#email")
else:
err_msg = email_request_form.errors.get("new_email") or email_request_form.errors.get(
"current_password"
)
if err_msg:
messages.error(request, err_msg[0])
else:
messages.error(request, "Could not process email change. Please review the form.")
return redirect(reverse("account_settings") + "#email")

elif action == "confirm_email_change":
email_confirm_form = EmailChangeConfirmForm(request.POST)
if email_confirm_form.is_valid():
success, msg = confirm_email_change(
user=user,
submitted_code=email_confirm_form.cleaned_data["code"],
request=request,
)
if success:
messages.success(request, msg)
else:
messages.error(request, msg)
return redirect(reverse("account_settings") + "#email")
else:
err_msg = email_confirm_form.errors.get("code")
if err_msg:
messages.error(request, err_msg[0])
else:
messages.error(request, "Please enter a valid 6-digit verification code.")
return redirect(reverse("account_settings") + "#email")

elif action == "resend_email_change_otp":
pending = PendingEmailChange.objects.filter(user=user).first()
if not pending:
messages.error(request, "No pending email change request found.")
return redirect(reverse("account_settings") + "#email")

try:
_, raw_otp = issue_otp(
user=user,
email=pending.new_email,
purpose=EmailVerificationCode.PURPOSE_CHANGE_EMAIL,
)
send_templated_email(
subject="Passli Vault: Verify Your New Email Address",
template_base="email_change_otp",
context={
"username": user.username,
"new_email": pending.new_email,
"otp_code": raw_otp,
"ttl_minutes": 15,
},
recipient_list=[pending.new_email],
fail_silently=True,
)
messages.success(request, f"A fresh verification code has been dispatched to {pending.new_email}.")
except ValidationError as exc:
messages.warning(request, exc.message if hasattr(exc, "message") else str(exc))
return redirect(reverse("account_settings") + "#email")

elif action == "cancel_email_change":
cancel_email_change(user, request=request)
messages.info(request, "Pending email change request cancelled.")
return redirect(reverse("account_settings") + "#email")

# GET request form instances and context
profile_form = ProfileForm(initial={"display_name": profile.display_name})
avatar_form = AvatarUploadForm()
username_form = UsernameChangeForm(user=user)
email_request_form = EmailChangeRequestForm(user=user)
email_confirm_form = EmailChangeConfirmForm()
pending_email_change = PendingEmailChange.objects.filter(user=user).first()
recent_activity = AccountEvent.objects.filter(user=user).order_by("-created_at")[:10]

resend_cooldown_seconds = 0
if pending_email_change:
latest_code = (
EmailVerificationCode.objects.filter(
user=user,
purpose=EmailVerificationCode.PURPOSE_CHANGE_EMAIL,
)
.order_by("-created_at")
.first()
)
if latest_code:
elapsed = (timezone.now() - latest_code.created_at).total_seconds()
if elapsed < 60:
resend_cooldown_seconds = max(0, int(60 - elapsed))

context = {
"profile": profile,
"profile_form": profile_form,
"avatar_form": avatar_form,
"username_form": username_form,
"email_request_form": email_request_form,
"email_confirm_form": email_confirm_form,
"pending_email_change": pending_email_change,
"resend_cooldown_seconds": resend_cooldown_seconds,
"cooldown_status": cooldown_status,
"recent_activity": recent_activity,
"has_avatar": bool(profile.avatar_path),
Expand Down
71 changes: 71 additions & 0 deletions static/css/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,67 @@
outline-offset: 2px;
}

/* Pending Alert Banner */
.settings-pending-alert {
background-color: var(--bg-subtle);
border: 1px solid var(--border-light);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.25rem;
display: flex;
align-items: flex-start;
gap: 0.75rem;
}

.settings-pending-alert-icon {
color: var(--brand-blue);
display: flex;
align-items: center;
padding-top: 0.1rem;
}

/* 6-Digit OTP Box UI & Progressive Enhancement */
.settings-otp-wrapper {
display: none;
gap: 8px;
margin: 0.5rem 0 0.5rem;
align-items: center;
}

.js-ready .settings-otp-wrapper {
display: flex;
}

.js-ready .settings-otp-fallback {
display: none;
}

.settings-otp-box {
width: 48px;
height: 54px;
font-family: 'JetBrains Mono', monospace;
font-size: 1.5rem;
font-weight: 600;
text-align: center;
border: 1.5px solid var(--border-light);
border-radius: 8px;
background: var(--bg-surface);
color: var(--text-main);
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
-webkit-appearance: none;
margin: 0;
}

.settings-otp-box:focus {
border-color: var(--brand-navy);
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.1);
}

.settings-otp-box.filled {
border-color: #94a3b8;
}

/* Mobile Responsiveness */
@media (max-width: 768px) {
.settings-layout {
Expand Down Expand Up @@ -383,6 +444,16 @@
.settings-card {
padding: 1.25rem;
}

.settings-otp-wrapper {
gap: 6px;
}

.settings-otp-box {
width: 40px;
height: 48px;
font-size: 1.25rem;
}
}

@media (prefers-reduced-motion: reduce) {
Expand Down
Loading
Loading