From 501b225e4026a75a9827260056676830022eea2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Casaj=C3=BAs?= Date: Thu, 29 Feb 2024 11:20:29 +0100 Subject: [PATCH] Require sudo for account changes (#2041) * Move accounts settings under sudo * Fixed sudo mode * Add a log message * Update test * Renamed sudo_setting to account_setting * Moved simple login data export and alias/import export to account settings * Move account settings to the top-right dropdown --- app/api/views/auth.py | 2 +- app/auth/views/change_email.py | 3 + app/auth/views/forgot_password.py | 2 +- app/dashboard/__init__.py | 2 + app/dashboard/views/account_setting.py | 242 +++++++++++++++++++++++ app/dashboard/views/alias_export.py | 2 + app/dashboard/views/batch_import.py | 2 + app/dashboard/views/setting.py | 185 +---------------- templates/dashboard/account_setting.html | 179 +++++++++++++++++ templates/dashboard/setting.html | 206 ++----------------- templates/header.html | 4 + tests/dashboard/test_setting.py | 2 +- tests/dashboard/test_sudo_setting.py | 28 +++ 13 files changed, 479 insertions(+), 380 deletions(-) create mode 100644 app/dashboard/views/account_setting.py create mode 100644 templates/dashboard/account_setting.html create mode 100644 tests/dashboard/test_sudo_setting.py diff --git a/app/api/views/auth.py b/app/api/views/auth.py index b77036ae..af180d83 100644 --- a/app/api/views/auth.py +++ b/app/api/views/auth.py @@ -11,7 +11,7 @@ from itsdangerous import Signer from app import email_utils from app.api.base import api_bp from app.config import FLASK_SECRET, DISABLE_REGISTRATION -from app.dashboard.views.setting import send_reset_password_email +from app.dashboard.views.account_setting import send_reset_password_email from app.db import Session from app.email_utils import ( email_can_be_used_as_mailbox, diff --git a/app/auth/views/change_email.py b/app/auth/views/change_email.py index 9e70c882..ff93c70a 100644 --- a/app/auth/views/change_email.py +++ b/app/auth/views/change_email.py @@ -3,6 +3,7 @@ from flask_login import login_user from app.auth.base import auth_bp from app.db import Session +from app.log import LOG from app.models import EmailChange, ResetPasswordCode @@ -22,12 +23,14 @@ def change_email(): return render_template("auth/change_email.html") user = email_change.user + old_email = user.email user.email = email_change.new_email EmailChange.delete(email_change.id) ResetPasswordCode.filter_by(user_id=user.id).delete() Session.commit() + LOG.i(f"User {user} has changed their email from {old_email} to {user.email}") flash("Your new email has been updated", "success") login_user(user) diff --git a/app/auth/views/forgot_password.py b/app/auth/views/forgot_password.py index 42246a1f..2dbccd89 100644 --- a/app/auth/views/forgot_password.py +++ b/app/auth/views/forgot_password.py @@ -3,7 +3,7 @@ from flask_wtf import FlaskForm from wtforms import StringField, validators from app.auth.base import auth_bp -from app.dashboard.views.setting import send_reset_password_email +from app.dashboard.views.account_setting import send_reset_password_email from app.extensions import limiter from app.log import LOG from app.models import User diff --git a/app/dashboard/__init__.py b/app/dashboard/__init__.py index 2a0c5c14..50fdbb79 100644 --- a/app/dashboard/__init__.py +++ b/app/dashboard/__init__.py @@ -32,6 +32,7 @@ from .views import ( delete_account, notification, support, + account_setting, ) __all__ = [ @@ -68,4 +69,5 @@ __all__ = [ "delete_account", "notification", "support", + "account_setting", ] diff --git a/app/dashboard/views/account_setting.py b/app/dashboard/views/account_setting.py new file mode 100644 index 00000000..5e3e0583 --- /dev/null +++ b/app/dashboard/views/account_setting.py @@ -0,0 +1,242 @@ +import arrow +from flask import ( + render_template, + request, + redirect, + url_for, + flash, +) +from flask_login import login_required, current_user + +from app import email_utils +from app.config import ( + URL, + FIRST_ALIAS_DOMAIN, + ALIAS_RANDOM_SUFFIX_LENGTH, + CONNECT_WITH_PROTON, +) +from app.dashboard.base import dashboard_bp +from app.dashboard.views.enter_sudo import sudo_required +from app.dashboard.views.mailbox_detail import ChangeEmailForm +from app.db import Session +from app.email_utils import ( + email_can_be_used_as_mailbox, + personal_email_already_used, +) +from app.extensions import limiter +from app.jobs.export_user_data_job import ExportUserDataJob +from app.log import LOG +from app.models import ( + BlockBehaviourEnum, + PlanEnum, + ResetPasswordCode, + EmailChange, + User, + Alias, + AliasGeneratorEnum, + SenderFormatEnum, + UnsubscribeBehaviourEnum, +) +from app.proton.utils import perform_proton_account_unlink +from app.utils import ( + random_string, + CSRFValidationForm, + canonicalize_email, +) + + +@dashboard_bp.route("/account_setting", methods=["GET", "POST"]) +@login_required +@sudo_required +@limiter.limit("5/minute", methods=["POST"]) +def account_setting(): + change_email_form = ChangeEmailForm() + csrf_form = CSRFValidationForm() + + email_change = EmailChange.get_by(user_id=current_user.id) + if email_change: + pending_email = email_change.new_email + else: + pending_email = None + + if request.method == "POST": + if not csrf_form.validate(): + flash("Invalid request", "warning") + return redirect(url_for("dashboard.setting")) + if request.form.get("form-name") == "update-email": + if change_email_form.validate(): + # whether user can proceed with the email update + new_email_valid = True + new_email = canonicalize_email(change_email_form.email.data) + if new_email != current_user.email and not pending_email: + # check if this email is not already used + if personal_email_already_used(new_email) or Alias.get_by( + email=new_email + ): + flash(f"Email {new_email} already used", "error") + new_email_valid = False + elif not email_can_be_used_as_mailbox(new_email): + flash( + "You cannot use this email address as your personal inbox.", + "error", + ) + new_email_valid = False + # a pending email change with the same email exists from another user + elif EmailChange.get_by(new_email=new_email): + other_email_change: EmailChange = EmailChange.get_by( + new_email=new_email + ) + LOG.w( + "Another user has a pending %s with the same email address. Current user:%s", + other_email_change, + current_user, + ) + + if other_email_change.is_expired(): + LOG.d( + "delete the expired email change %s", other_email_change + ) + EmailChange.delete(other_email_change.id) + Session.commit() + else: + flash( + "You cannot use this email address as your personal inbox.", + "error", + ) + new_email_valid = False + + if new_email_valid: + email_change = EmailChange.create( + user_id=current_user.id, + code=random_string( + 60 + ), # todo: make sure the code is unique + new_email=new_email, + ) + Session.commit() + send_change_email_confirmation(current_user, email_change) + flash( + "A confirmation email is on the way, please check your inbox", + "success", + ) + return redirect(url_for("dashboard.account_setting")) + elif request.form.get("form-name") == "change-password": + flash( + "You are going to receive an email containing instructions to change your password", + "success", + ) + send_reset_password_email(current_user) + return redirect(url_for("dashboard.account_setting")) + elif request.form.get("form-name") == "send-full-user-report": + if ExportUserDataJob(current_user).store_job_in_db(): + flash( + "You will receive your SimpleLogin data via email shortly", + "success", + ) + else: + flash("An export of your data is currently in progress", "error") + + partner_sub = None + partner_name = None + + return render_template( + "dashboard/account_setting.html", + csrf_form=csrf_form, + PlanEnum=PlanEnum, + SenderFormatEnum=SenderFormatEnum, + BlockBehaviourEnum=BlockBehaviourEnum, + change_email_form=change_email_form, + pending_email=pending_email, + AliasGeneratorEnum=AliasGeneratorEnum, + UnsubscribeBehaviourEnum=UnsubscribeBehaviourEnum, + partner_sub=partner_sub, + partner_name=partner_name, + FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN, + ALIAS_RAND_SUFFIX_LENGTH=ALIAS_RANDOM_SUFFIX_LENGTH, + connect_with_proton=CONNECT_WITH_PROTON, + ) + + +def send_reset_password_email(user): + """ + generate a new ResetPasswordCode and send it over email to user + """ + # the activation code is valid for 1h + reset_password_code = ResetPasswordCode.create( + user_id=user.id, code=random_string(60) + ) + Session.commit() + + reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}" + + email_utils.send_reset_password_email(user.email, reset_password_link) + + +def send_change_email_confirmation(user: User, email_change: EmailChange): + """ + send confirmation email to the new email address + """ + + link = f"{URL}/auth/change_email?code={email_change.code}" + + email_utils.send_change_email(email_change.new_email, user.email, link) + + +@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"]) +@limiter.limit("5/hour") +@login_required +@sudo_required +def resend_email_change(): + form = CSRFValidationForm() + if not form.validate(): + flash("Invalid request. Please try again", "warning") + return redirect(url_for("dashboard.setting")) + email_change = EmailChange.get_by(user_id=current_user.id) + if email_change: + # extend email change expiration + email_change.expired = arrow.now().shift(hours=12) + Session.commit() + + send_change_email_confirmation(current_user, email_change) + flash("A confirmation email is on the way, please check your inbox", "success") + return redirect(url_for("dashboard.setting")) + else: + flash( + "You have no pending email change. Redirect back to Setting page", "warning" + ) + return redirect(url_for("dashboard.setting")) + + +@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"]) +@login_required +@sudo_required +def cancel_email_change(): + form = CSRFValidationForm() + if not form.validate(): + flash("Invalid request. Please try again", "warning") + return redirect(url_for("dashboard.setting")) + email_change = EmailChange.get_by(user_id=current_user.id) + if email_change: + EmailChange.delete(email_change.id) + Session.commit() + flash("Your email change is cancelled", "success") + return redirect(url_for("dashboard.setting")) + else: + flash( + "You have no pending email change. Redirect back to Setting page", "warning" + ) + return redirect(url_for("dashboard.setting")) + + +@dashboard_bp.route("/unlink_proton_account", methods=["POST"]) +@login_required +@sudo_required +def unlink_proton_account(): + csrf_form = CSRFValidationForm() + if not csrf_form.validate(): + flash("Invalid request", "warning") + return redirect(url_for("dashboard.setting")) + + perform_proton_account_unlink(current_user) + flash("Your Proton account has been unlinked", "success") + return redirect(url_for("dashboard.setting")) diff --git a/app/dashboard/views/alias_export.py b/app/dashboard/views/alias_export.py index 9d48b382..f21df4b7 100644 --- a/app/dashboard/views/alias_export.py +++ b/app/dashboard/views/alias_export.py @@ -1,9 +1,11 @@ from app.dashboard.base import dashboard_bp from flask_login import login_required, current_user from app.alias_utils import alias_export_csv +from app.dashboard.views.enter_sudo import sudo_required @dashboard_bp.route("/alias_export", methods=["GET"]) @login_required +@sudo_required def alias_export_route(): return alias_export_csv(current_user) diff --git a/app/dashboard/views/batch_import.py b/app/dashboard/views/batch_import.py index 7a23d37d..7521cc09 100644 --- a/app/dashboard/views/batch_import.py +++ b/app/dashboard/views/batch_import.py @@ -5,6 +5,7 @@ from flask_login import login_required, current_user from app import s3 from app.config import JOB_BATCH_IMPORT from app.dashboard.base import dashboard_bp +from app.dashboard.views.enter_sudo import sudo_required from app.db import Session from app.log import LOG from app.models import File, BatchImport, Job @@ -13,6 +14,7 @@ from app.utils import random_string, CSRFValidationForm @dashboard_bp.route("/batch_import", methods=["GET", "POST"]) @login_required +@sudo_required def batch_import_route(): # only for users who have custom domains if not current_user.verified_custom_domains(): diff --git a/app/dashboard/views/setting.py b/app/dashboard/views/setting.py index f177ee43..cefbaf58 100644 --- a/app/dashboard/views/setting.py +++ b/app/dashboard/views/setting.py @@ -13,34 +13,24 @@ from flask_login import login_required, current_user from flask_wtf import FlaskForm from flask_wtf.file import FileField from wtforms import StringField, validators -from wtforms.fields.html5 import EmailField -from app import s3, email_utils +from app import s3 from app.config import ( - URL, FIRST_ALIAS_DOMAIN, ALIAS_RANDOM_SUFFIX_LENGTH, CONNECT_WITH_PROTON, ) from app.dashboard.base import dashboard_bp from app.db import Session -from app.email_utils import ( - email_can_be_used_as_mailbox, - personal_email_already_used, -) from app.errors import ProtonPartnerNotSetUp from app.extensions import limiter from app.image_validation import detect_image_format, ImageFormat -from app.jobs.export_user_data_job import ExportUserDataJob from app.log import LOG from app.models import ( BlockBehaviourEnum, PlanEnum, File, - ResetPasswordCode, EmailChange, - User, - Alias, CustomDomain, AliasGeneratorEnum, AliasSuffixEnum, @@ -53,11 +43,10 @@ from app.models import ( PartnerSubscription, UnsubscribeBehaviourEnum, ) -from app.proton.utils import get_proton_partner, perform_proton_account_unlink +from app.proton.utils import get_proton_partner from app.utils import ( random_string, CSRFValidationForm, - canonicalize_email, ) @@ -66,12 +55,6 @@ class SettingForm(FlaskForm): profile_picture = FileField("Profile Picture") -class ChangeEmailForm(FlaskForm): - email = EmailField( - "email", validators=[validators.DataRequired(), validators.Email()] - ) - - class PromoCodeForm(FlaskForm): code = StringField("Name", validators=[validators.DataRequired()]) @@ -109,7 +92,6 @@ def get_partner_subscription_and_name( def setting(): form = SettingForm() promo_form = PromoCodeForm() - change_email_form = ChangeEmailForm() csrf_form = CSRFValidationForm() email_change = EmailChange.get_by(user_id=current_user.id) @@ -122,63 +104,7 @@ def setting(): if not csrf_form.validate(): flash("Invalid request", "warning") return redirect(url_for("dashboard.setting")) - if request.form.get("form-name") == "update-email": - if change_email_form.validate(): - # whether user can proceed with the email update - new_email_valid = True - new_email = canonicalize_email(change_email_form.email.data) - if new_email != current_user.email and not pending_email: - # check if this email is not already used - if personal_email_already_used(new_email) or Alias.get_by( - email=new_email - ): - flash(f"Email {new_email} already used", "error") - new_email_valid = False - elif not email_can_be_used_as_mailbox(new_email): - flash( - "You cannot use this email address as your personal inbox.", - "error", - ) - new_email_valid = False - # a pending email change with the same email exists from another user - elif EmailChange.get_by(new_email=new_email): - other_email_change: EmailChange = EmailChange.get_by( - new_email=new_email - ) - LOG.w( - "Another user has a pending %s with the same email address. Current user:%s", - other_email_change, - current_user, - ) - if other_email_change.is_expired(): - LOG.d( - "delete the expired email change %s", other_email_change - ) - EmailChange.delete(other_email_change.id) - Session.commit() - else: - flash( - "You cannot use this email address as your personal inbox.", - "error", - ) - new_email_valid = False - - if new_email_valid: - email_change = EmailChange.create( - user_id=current_user.id, - code=random_string( - 60 - ), # todo: make sure the code is unique - new_email=new_email, - ) - Session.commit() - send_change_email_confirmation(current_user, email_change) - flash( - "A confirmation email is on the way, please check your inbox", - "success", - ) - return redirect(url_for("dashboard.setting")) if request.form.get("form-name") == "update-profile": if form.validate(): profile_updated = False @@ -222,15 +148,6 @@ def setting(): if profile_updated: flash("Your profile has been updated", "success") return redirect(url_for("dashboard.setting")) - - elif request.form.get("form-name") == "change-password": - flash( - "You are going to receive an email containing instructions to change your password", - "success", - ) - send_reset_password_email(current_user) - return redirect(url_for("dashboard.setting")) - elif request.form.get("form-name") == "notification-preference": choose = request.form.get("notification") if choose == "on": @@ -240,7 +157,6 @@ def setting(): Session.commit() flash("Your notification preference has been updated", "success") return redirect(url_for("dashboard.setting")) - elif request.form.get("form-name") == "change-alias-generator": scheme = int(request.form.get("alias-generator-scheme")) if AliasGeneratorEnum.has_value(scheme): @@ -248,7 +164,6 @@ def setting(): Session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) - elif request.form.get("form-name") == "change-random-alias-default-domain": default_domain = request.form.get("random-alias-default-domain") @@ -287,7 +202,6 @@ def setting(): Session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) - elif request.form.get("form-name") == "random-alias-suffix": scheme = int(request.form.get("random-alias-suffix-generator")) if AliasSuffixEnum.has_value(scheme): @@ -295,7 +209,6 @@ def setting(): Session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) - elif request.form.get("form-name") == "change-sender-format": sender_format = int(request.form.get("sender-format")) if SenderFormatEnum.has_value(sender_format): @@ -305,7 +218,6 @@ def setting(): flash("Your sender format preference has been updated", "success") Session.commit() return redirect(url_for("dashboard.setting")) - elif request.form.get("form-name") == "replace-ra": choose = request.form.get("replace-ra") if choose == "on": @@ -315,7 +227,6 @@ def setting(): Session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) - elif request.form.get("form-name") == "sender-in-ra": choose = request.form.get("enable") if choose == "on": @@ -325,7 +236,6 @@ def setting(): Session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) - elif request.form.get("form-name") == "expand-alias-info": choose = request.form.get("enable") if choose == "on": @@ -387,14 +297,6 @@ def setting(): Session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) - elif request.form.get("form-name") == "send-full-user-report": - if ExportUserDataJob(current_user).store_job_in_db(): - flash( - "You will receive your SimpleLogin data via email shortly", - "success", - ) - else: - flash("An export of your data is currently in progress", "error") manual_sub = ManualSubscription.get_by(user_id=current_user.id) apple_sub = AppleSubscription.get_by(user_id=current_user.id) @@ -417,7 +319,6 @@ def setting(): SenderFormatEnum=SenderFormatEnum, BlockBehaviourEnum=BlockBehaviourEnum, promo_form=promo_form, - change_email_form=change_email_form, pending_email=pending_email, AliasGeneratorEnum=AliasGeneratorEnum, UnsubscribeBehaviourEnum=UnsubscribeBehaviourEnum, @@ -432,85 +333,3 @@ def setting(): connect_with_proton=CONNECT_WITH_PROTON, proton_linked_account=proton_linked_account, ) - - -def send_reset_password_email(user): - """ - generate a new ResetPasswordCode and send it over email to user - """ - # the activation code is valid for 1h - reset_password_code = ResetPasswordCode.create( - user_id=user.id, code=random_string(60) - ) - Session.commit() - - reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}" - - email_utils.send_reset_password_email(user.email, reset_password_link) - - -def send_change_email_confirmation(user: User, email_change: EmailChange): - """ - send confirmation email to the new email address - """ - - link = f"{URL}/auth/change_email?code={email_change.code}" - - email_utils.send_change_email(email_change.new_email, user.email, link) - - -@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"]) -@limiter.limit("5/hour") -@login_required -def resend_email_change(): - form = CSRFValidationForm() - if not form.validate(): - flash("Invalid request. Please try again", "warning") - return redirect(url_for("dashboard.setting")) - email_change = EmailChange.get_by(user_id=current_user.id) - if email_change: - # extend email change expiration - email_change.expired = arrow.now().shift(hours=12) - Session.commit() - - send_change_email_confirmation(current_user, email_change) - flash("A confirmation email is on the way, please check your inbox", "success") - return redirect(url_for("dashboard.setting")) - else: - flash( - "You have no pending email change. Redirect back to Setting page", "warning" - ) - return redirect(url_for("dashboard.setting")) - - -@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"]) -@login_required -def cancel_email_change(): - form = CSRFValidationForm() - if not form.validate(): - flash("Invalid request. Please try again", "warning") - return redirect(url_for("dashboard.setting")) - email_change = EmailChange.get_by(user_id=current_user.id) - if email_change: - EmailChange.delete(email_change.id) - Session.commit() - flash("Your email change is cancelled", "success") - return redirect(url_for("dashboard.setting")) - else: - flash( - "You have no pending email change. Redirect back to Setting page", "warning" - ) - return redirect(url_for("dashboard.setting")) - - -@dashboard_bp.route("/unlink_proton_account", methods=["POST"]) -@login_required -def unlink_proton_account(): - csrf_form = CSRFValidationForm() - if not csrf_form.validate(): - flash("Invalid request", "warning") - return redirect(url_for("dashboard.setting")) - - perform_proton_account_unlink(current_user) - flash("Your Proton account has been unlinked", "success") - return redirect(url_for("dashboard.setting")) diff --git a/templates/dashboard/account_setting.html b/templates/dashboard/account_setting.html new file mode 100644 index 00000000..a6ba0b51 --- /dev/null +++ b/templates/dashboard/account_setting.html @@ -0,0 +1,179 @@ +{% extends "default.html" %} + +{% set active_page = "setting" %} +{% block title %}Settings{% endblock %} +{% block head %} + + +{% endblock %} +{% block default_content %} + +
+ +
+
+
+ + {{ change_email_form.csrf_token }} +
Account Email
+
+ This email address is used to log in to SimpleLogin. +
+ If you want to change the mailbox that emails are forwarded to, use the + + Mailboxes page + + instead. +
+
+ + {{ change_email_form.email(class="form-control", value=current_user.email, readonly=pending_email != None) }} + {{ render_field_errors(change_email_form.email) }} +
+ +
+ {% if pending_email %} + +
+ Pending email change: {{ pending_email }} +
+ {{ change_email_form.csrf_token }} + Resend confirmation email +
+
+ {{ change_email_form.csrf_token }} + Cancel email change +
+
+ {% endif %} +
+
+ + +
+
+
Password
+
You will receive an email containing instructions on how to change your password.
+
+ {{ csrf_form.csrf_token }} + + +
+
+
+ + +
+
+
Two Factor Authentication
+
+ Secure your account with 2FA, you'll be asked for a code generated through an app when you login. +
+
+ {% if not current_user.enable_otp %} + + Setup TOTP + {% else %} + Disable TOTP + {% endif %} +
+
+ + +
+
+
Security Key (WebAuthn)
+
+ You can secure your account by linking either your FIDO-supported physical key such as Yubikey, Google + Titan, + or a device with appropriate hardware to your account. +
+ {% if current_user.fido_uuid is none %} + + Setup WebAuthn + {% else %} + Manage WebAuthn + {% endif %} +
+
+ + +
+
+
Alias import/export
+
+ You can import your aliases created on other platforms into SimpleLogin. + You can also export your aliases to a readable csv format for a future batch import. +
+ Batch Import + Export Aliases +
+
+ + +
+
+
SimpleLogin data export
+
+ As per GDPR (General Data Protection Regulation) law, you can request a copy of your data which are stored on + SimpleLogin. + A zip file that contains all information will be sent to your SimpleLogin account address. +
+
+
+
+ {{ csrf_form.csrf_token }} + + +
+
+
+
+
+ + +
+
+
Account Deletion
+
If SimpleLogin isn't the right fit for you, you can simply delete your account.
+ Delete account +
+
+ +
+{% endblock %} +{% block script %} + + +{% endblock %} diff --git a/templates/dashboard/setting.html b/templates/dashboard/setting.html index 550995a8..e3e7b17a 100644 --- a/templates/dashboard/setting.html +++ b/templates/dashboard/setting.html @@ -88,45 +88,6 @@ - -
-
-
Two Factor Authentication
-
- Secure your account with 2FA, you'll be asked for a code generated through an app when you login. -
-
- {% if not current_user.enable_otp %} - - Setup TOTP - {% else %} - Disable TOTP - {% endif %} -
-
- - -
-
-
Security Key (WebAuthn)
-
- You can secure your account by linking either your FIDO-supported physical key such as Yubikey, Google - Titan, - or a device with appropriate hardware to your account. -
- {% if current_user.fido_uuid is none %} - - Setup WebAuthn - {% else %} - Manage WebAuthn - {% endif %} -
-
-
@@ -179,52 +140,6 @@
- -
-
-
- - {{ change_email_form.csrf_token }} -
Account Email
-
- This email address is used to log in to SimpleLogin. -
- If you want to change the mailbox that emails are forwarded to, use the - - Mailboxes page - - instead. -
-
- - {{ change_email_form.email(class="form-control", value=current_user.email, readonly=pending_email != None) }} - {{ render_field_errors(change_email_form.email) }} -
- -
- {% if pending_email %} - -
- Pending email change: {{ pending_email }} -
- {{ change_email_form.csrf_token }} - Resend confirmation email -
-
- {{ change_email_form.csrf_token }} - Cancel email change -
-
- {% endif %} -
-
- {% if connect_with_proton %} @@ -265,32 +180,11 @@
{% endif %} - -
-
-
Password
-
- You will receive an email containing instructions on how to change your password. -
-
- {{ csrf_form.csrf_token }} - - -
-
-
-
-
- Aliases -
-
- Change the way random aliases are generated by default. -
+
Aliases
+
Change the way random aliases are generated by default.
{{ csrf_form.csrf_token }} @@ -306,13 +200,9 @@ on {{ AliasGeneratorEnum.uuid.name.upper() }} - +
-
- Select the default domain for aliases. -
+
Select the default domain for aliases.
{{ csrf_form.csrf_token }} {% endfor %} - +
-
- Select the default suffix generator for aliases. -
+
Select the default suffix generator for aliases.
{{ csrf_form.csrf_token }} @@ -358,9 +244,7 @@ Random combination of {{ ALIAS_RAND_SUFFIX_LENGTH }} letter and digits - +
@@ -368,9 +252,7 @@
-
- Sender Address Format -
+
Sender Address Format
When your alias receives an email, say from: John Wick <john@wick.com>, SimpleLogin forwards it to your mailbox. @@ -403,9 +285,7 @@ No Name (i.e. only reverse-alias) - +
@@ -415,9 +295,7 @@
Reverse Alias Replacement -
- Experimental -
+
Experimental
When replying to a forwarded email, the reverse-alias can be automatically included @@ -434,13 +312,9 @@ name="replace-ra" {% if current_user.replace_reverse_alias %} checked{% endif %} class="form-check-input"> - +
- +
@@ -709,62 +583,6 @@ -
-
-
- Alias import/export -
-
- You can import your aliases created on other platforms into SimpleLogin. - You can also export your aliases to a readable csv format for a future batch import. -
- - Batch Import - - - Export Aliases - -
-
-
-
-
- SimpleLogin data export -
-
- As per GDPR (General Data Protection Regulation) law, you can request a copy of your data which are stored on - SimpleLogin. - A zip file that contains all information will be sent to your SimpleLogin account address. -
-
-
-
- {{ csrf_form.csrf_token }} - - -
-
-
-
-
-
-
-
- Account Deletion -
-
- If SimpleLogin isn't the right fit for you, you can simply delete your account. -
- - Delete account - -
-
{% endblock %} {% block script %} diff --git a/templates/header.html b/templates/header.html index 48776063..4920c896 100644 --- a/templates/header.html +++ b/templates/header.html @@ -148,6 +148,10 @@ API Keys + + Account settings + Sign out diff --git a/tests/dashboard/test_setting.py b/tests/dashboard/test_setting.py index 6f9274b3..20accbf9 100644 --- a/tests/dashboard/test_setting.py +++ b/tests/dashboard/test_setting.py @@ -13,7 +13,7 @@ def test_setup_done(flask_client): noncanonical_email = f"nonca.{random_email()}" r = flask_client.post( - url_for("dashboard.setting"), + url_for("dashboard.account_setting"), data={ "form-name": "update-email", "email": noncanonical_email, diff --git a/tests/dashboard/test_sudo_setting.py b/tests/dashboard/test_sudo_setting.py new file mode 100644 index 00000000..20accbf9 --- /dev/null +++ b/tests/dashboard/test_sudo_setting.py @@ -0,0 +1,28 @@ +from flask import url_for + +from app import config +from app.models import EmailChange +from app.utils import canonicalize_email +from tests.utils import login, random_email, create_new_user + + +def test_setup_done(flask_client): + config.SKIP_MX_LOOKUP_ON_CHECK = True + user = create_new_user() + login(flask_client, user) + noncanonical_email = f"nonca.{random_email()}" + + r = flask_client.post( + url_for("dashboard.account_setting"), + data={ + "form-name": "update-email", + "email": noncanonical_email, + }, + follow_redirects=True, + ) + + assert r.status_code == 200 + email_change = EmailChange.get_by(user_id=user.id) + assert email_change is not None + assert email_change.new_email == canonicalize_email(noncanonical_email) + config.SKIP_MX_LOOKUP_ON_CHECK = False