From b391e176db7f1e8326910b9f89bfa3d955fc2001 Mon Sep 17 00:00:00 2001 From: Julian Rother <julian@cccv.de> Date: Wed, 19 Oct 2022 22:18:54 +0200 Subject: [PATCH] Per-service email preferences Also fixes a minor email-related bug in the admin interface and bad texts/translations in the selfservice UI. --- tests/test_selfservice.py | 73 +++++--- tests/test_services.py | 70 +++++++- tests/test_user.py | 11 +- ...b733ec856_per_service_email_preferences.py | 42 +++++ uffd/models/service.py | 30 +++- uffd/templates/selfservice/self.html | 44 ++++- uffd/templates/service/show.html | 7 + uffd/templates/user/show.html | 13 +- uffd/translations/de/LC_MESSAGES/messages.mo | Bin 38331 -> 38702 bytes uffd/translations/de/LC_MESSAGES/messages.po | 170 ++++++++++-------- uffd/views/selfservice.py | 18 +- uffd/views/service.py | 1 + uffd/views/user.py | 20 ++- 13 files changed, 378 insertions(+), 121 deletions(-) create mode 100644 uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py diff --git a/tests/test_selfservice.py b/tests/test_selfservice.py index b31e579e..6f686f4b 100644 --- a/tests/test_selfservice.py +++ b/tests/test_selfservice.py @@ -5,11 +5,10 @@ import unittest from flask import url_for, request from uffd import create_app, db -from uffd.models import PasswordToken, User, UserEmail, Role, RoleGroup +from uffd.models import PasswordToken, User, UserEmail, Role, RoleGroup, Service, ServiceUser from utils import dump, UffdTestCase - class TestSelfservice(UffdTestCase): def test_index(self): self.login_as('user') @@ -185,10 +184,14 @@ class TestSelfservice(UffdTestCase): def test_update_email_preferences(self): self.login_as('user') + user_id = self.get_user().id email = UserEmail(user=self.get_user(), address='new@example.com', verified=True) db.session.add(email) + service = Service(name='service', enable_email_preferences=True) + db.session.add(service) db.session.commit() email_id = email.id + service_id = service.id old_email_id = self.get_user().primary_email.id r = self.client.post(path=url_for('selfservice.update_email_preferences'), data={'primary_email': str(email_id), 'recovery_email': 'primary'}, @@ -203,42 +206,68 @@ class TestSelfservice(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertEqual(self.get_user().primary_email.id, old_email_id) self.assertEqual(self.get_user().recovery_email.id, email_id) + r = self.client.post(path=url_for('selfservice.update_email_preferences'), + data={'primary_email': str(old_email_id), 'recovery_email': 'primary', f'service_{service_id}_email': 'primary'}, + follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertIsNone(ServiceUser.query.get((service_id, user_id)).service_email) + r = self.client.post(path=url_for('selfservice.update_email_preferences'), + data={'primary_email': str(old_email_id), 'recovery_email': 'primary', f'service_{service_id}_email': str(email_id)}, + follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(ServiceUser.query.get((service_id, user_id)).service_email.id, email_id) def test_update_email_preferences_unverified(self): self.login_as('user') + user_id = self.get_user().id email = UserEmail(user=self.get_user(), address='new@example.com') db.session.add(email) + service = Service(name='service', enable_email_preferences=True) + db.session.add(service) db.session.commit() email_id = email.id + service_id = service.id old_email_id = self.get_user().primary_email.id - r = self.client.post(path=url_for('selfservice.update_email_preferences'), - data={'primary_email': str(email_id), 'recovery_email': 'primary'}, - follow_redirects=True) - self.assertEqual(r.status_code, 400) + with self.assertRaises(Exception): + r = self.client.post(path=url_for('selfservice.update_email_preferences'), + data={'primary_email': str(email_id), 'recovery_email': 'primary'}, + follow_redirects=True) self.assertEqual(self.get_user().primary_email.address, 'test@example.com') - r = self.client.post(path=url_for('selfservice.update_email_preferences'), - data={'primary_email': str(old_email_id), 'recovery_email': str(email_id)}, - follow_redirects=True) - self.assertEqual(r.status_code, 400) + with self.assertRaises(Exception): + r = self.client.post(path=url_for('selfservice.update_email_preferences'), + data={'primary_email': str(old_email_id), 'recovery_email': str(email_id)}, + follow_redirects=True) self.assertIsNone(self.get_user().recovery_email) + with self.assertRaises(Exception): + r = self.client.post(path=url_for('selfservice.update_email_preferences'), + data={'primary_email': str(old_email_id), 'recovery_email': 'primary', f'service_{service_id}_email': str(email_id)}, + follow_redirects=True) + self.assertIsNone(ServiceUser.query.get((service_id, user_id)).service_email) def test_update_email_preferences_invalid(self): self.login_as('user') + user_id = self.get_user().id email = UserEmail(user=self.get_user(), address='new@example.com', verified=True) db.session.add(email) + service = Service(name='service', enable_email_preferences=True) + db.session.add(service) db.session.commit() - r = self.client.post(path=url_for('selfservice.update_email_preferences'), - data={'primary_email': str(email.id), 'recovery_email': '2342'}, - follow_redirects=True) - self.assertEqual(r.status_code, 400) - r = self.client.post(path=url_for('selfservice.update_email_preferences'), - data={'primary_email': 'primary', 'recovery_email': 'primary'}, - follow_redirects=True) - self.assertEqual(r.status_code, 400) - r = self.client.post(path=url_for('selfservice.update_email_preferences'), - data={'primary_email': '2342', 'recovery_email': 'primary'}, - follow_redirects=True) - self.assertEqual(r.status_code, 400) + with self.assertRaises(Exception): + r = self.client.post(path=url_for('selfservice.update_email_preferences'), + data={'primary_email': str(email.id), 'recovery_email': '2342'}, + follow_redirects=True) + with self.assertRaises(Exception): + r = self.client.post(path=url_for('selfservice.update_email_preferences'), + data={'primary_email': str(email.id), 'recovery_email': 'primary', f'service_{service_id}_email': '2342'}, + follow_redirects=True) + with self.assertRaises(Exception): + r = self.client.post(path=url_for('selfservice.update_email_preferences'), + data={'primary_email': 'primary', 'recovery_email': 'primary'}, + follow_redirects=True) + with self.assertRaises(Exception): + r = self.client.post(path=url_for('selfservice.update_email_preferences'), + data={'primary_email': '2342', 'recovery_email': 'primary'}, + follow_redirects=True) def test_change_password(self): self.login_as('user') diff --git a/tests/test_services.py b/tests/test_services.py index 0c697023..14ec4f70 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -7,7 +7,7 @@ from utils import dump, UffdTestCase from uffd.remailer import remailer from uffd.tasks import cleanup_task from uffd.database import db -from uffd.models import Service, ServiceUser, User +from uffd.models import Service, ServiceUser, User, UserEmail class TestServiceUser(UffdTestCase): def setUp(self): @@ -43,11 +43,26 @@ class TestServiceUser(UffdTestCase): db.session.commit() self.assertEqual(ServiceUser.query.count(), service_count * user_count) + def test_service_email(self): + user = self.get_user() + service = Service.query.filter_by(name='service1').first() + service_user = ServiceUser.query.get((service.id, user.id)) + self.assertEqual(service_user.service_email, None) + service_user.service_email = UserEmail(user=user, address='foo@bar', verified=True) + with self.assertRaises(Exception): + service_user.service_email = UserEmail(user=user, address='foo2@bar', verified=False) + with self.assertRaises(Exception): + service_user.service_email = UserEmail(user=self.get_admin(), address='foo3@bar', verified=True) + def test_real_email(self): user = self.get_user() service = Service.query.filter_by(name='service1').first() service_user = ServiceUser.query.get((service.id, user.id)) self.assertEqual(service_user.real_email, user.primary_email.address) + service_user.service_email = UserEmail(user=user, address='foo@bar', verified=True) + self.assertEqual(service_user.real_email, user.primary_email.address) + service.enable_email_preferences = True + self.assertEqual(service_user.real_email, service_user.service_email.address) def test_remailer_email(self): user = self.get_user() @@ -161,6 +176,59 @@ class TestServiceUser(UffdTestCase): self.assertEqual(run_query(remailer_email2_1), set()) self.assertEqual(run_query('invalid'), set()) + def test_filter_query_by_email_prefs(self): + def run_query(value): + return {(su.service_id, su.user_id) for su in ServiceUser.filter_query_by_email(ServiceUser.query, value)} + + user1 = self.get_user() + service1 = Service.query.filter_by(name='service1').first() # use_remailer=False + service2 = Service.query.filter_by(name='service2').first() # use_remailer=True + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + remailer_email1_1 = remailer.build_address(service1.id, user1.id) + remailer_email2_1 = remailer.build_address(service2.id, user1.id) + + self.app.config['REMAILER_DOMAIN'] = '' + self.assertEqual(run_query(user1.primary_email.address), { + (service1.id, user1.id), + (service2.id, user1.id), + }) + self.assertEqual(run_query('addr1-1@example.com'), set()) + self.assertEqual(run_query('addr2-1@example.com'), set()) + self.assertEqual(run_query(remailer_email1_1), set()) + self.assertEqual(run_query(remailer_email2_1), set()) + + ServiceUser.query.get((service1.id, user1.id)).service_email = UserEmail(user=user1, verified=True, address='addr1-1@example.com') + ServiceUser.query.get((service2.id, user1.id)).service_email = UserEmail(user=user1, verified=True, address='addr2-1@example.com') + self.assertEqual(run_query(user1.primary_email.address), { + (service1.id, user1.id), + (service2.id, user1.id), + }) + self.assertEqual(run_query('addr1-1@example.com'), set()) + self.assertEqual(run_query('addr2-1@example.com'), set()) + self.assertEqual(run_query(remailer_email1_1), set()) + self.assertEqual(run_query(remailer_email2_1), set()) + service1.enable_email_preferences = True + service2.enable_email_preferences = True + self.assertEqual(run_query(user1.primary_email.address), set()) + self.assertEqual(run_query('addr1-1@example.com'), { + (service1.id, user1.id), + }) + self.assertEqual(run_query('addr2-1@example.com'), { + (service2.id, user1.id), + }) + self.assertEqual(run_query(remailer_email1_1), set()) + self.assertEqual(run_query(remailer_email2_1), set()) + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + self.assertEqual(run_query(user1.primary_email.address), set()) + self.assertEqual(run_query('addr1-1@example.com'), { + (service1.id, user1.id), + }) + self.assertEqual(run_query('addr2-1@example.com'), set()) + self.assertEqual(run_query(remailer_email1_1), set()) + self.assertEqual(run_query(remailer_email2_1), { + (service2.id, user1.id), + }) + class TestServices(UffdTestCase): def setUpApp(self): self.app.config['SERVICES'] = [ diff --git a/tests/test_user.py b/tests/test_user.py index b023510c..81f3a292 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -6,7 +6,7 @@ import sqlalchemy from uffd import create_app, db from uffd.remailer import remailer -from uffd.models import User, UserEmail, Group, Role, RoleGroup, Service +from uffd.models import User, UserEmail, Group, Role, RoleGroup, Service, ServiceUser from utils import dump, UffdTestCase @@ -346,9 +346,14 @@ class TestUserViews(UffdTestCase): def test_update_email(self): user = self.get_user() email = UserEmail(user=user, address='foo@example.com') + service1 = Service(name='service1', enable_email_preferences=True) + service2 = Service(name='service2', enable_email_preferences=True) + db.session.add_all([service1, service2]) db.session.commit() email1_id = user.primary_email.id email2_id = email.id + service1_id = service1.id + service2_id = service2.id r = self.client.post(path=url_for('user.update', id=user.id), data={'loginname': 'testuser', f'email-{email1_id}-present': '1', @@ -357,6 +362,8 @@ class TestUserViews(UffdTestCase): f'newemail-1-address': 'new1@example.com', f'newemail-2-address': 'new2@example.com', f'newemail-2-verified': '1', 'primary_email': email2_id, 'recovery_email': email1_id, + f'service_{service1_id}_email': 'primary', + f'service_{service2_id}_email': email2_id, 'displayname': 'Test User', 'password': ''}, follow_redirects=True) dump('user_update_email', r) @@ -364,6 +371,8 @@ class TestUserViews(UffdTestCase): user = self.get_user() self.assertEqual(user.primary_email.id, email2_id) self.assertEqual(user.recovery_email.id, email1_id) + self.assertEqual(ServiceUser.query.get((service1.id, user.id)).service_email, None) + self.assertEqual(ServiceUser.query.get((service2.id, user.id)).service_email.id, email2_id) self.assertEqual( {email.address: email.verified for email in user.all_emails}, { diff --git a/uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py b/uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py new file mode 100644 index 00000000..e7afe43e --- /dev/null +++ b/uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py @@ -0,0 +1,42 @@ +"""Per-service email preferences + +Revision ID: e13b733ec856 +Revises: b273d7fdaa25 +Create Date: 2022-10-17 02:13:11.598210 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'e13b733ec856' +down_revision = 'b273d7fdaa25' +branch_labels = None +depends_on = None + +def upgrade(): + with op.batch_alter_table('service', schema=None) as batch_op: + batch_op.add_column(sa.Column('enable_email_preferences', sa.Boolean(), nullable=False, server_default=sa.false())) + with op.batch_alter_table('service_user', schema=None) as batch_op: + batch_op.add_column(sa.Column('service_email_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_service_user_service_email_id_user_email'), 'user_email', ['service_email_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL') + meta = sa.MetaData(bind=op.get_bind()) + service = sa.Table('service', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('access_group_id', sa.Integer(), nullable=True), + sa.Column('use_remailer', sa.Boolean(), nullable=False), + sa.Column('enable_email_preferences', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), + sa.UniqueConstraint('name', name=op.f('uq_service_name')) + ) + with op.batch_alter_table('service', copy_from=service) as batch_op: + batch_op.alter_column('enable_email_preferences', server_default=None) + +def downgrade(): + with op.batch_alter_table('service_user', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_service_user_service_email_id_user_email'), type_='foreignkey') + batch_op.drop_column('service_email_id') + with op.batch_alter_table('service', schema=None) as batch_op: + batch_op.drop_column('enable_email_preferences') diff --git a/uffd/models/service.py b/uffd/models/service.py index 131400f0..f6927e28 100644 --- a/uffd/models/service.py +++ b/uffd/models/service.py @@ -1,7 +1,7 @@ from flask import current_app from flask_babel import get_locale from sqlalchemy import Column, Integer, String, ForeignKey, Boolean -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, validates from uffd.database import db from uffd.remailer import remailer @@ -27,6 +27,7 @@ class Service(db.Model): api_clients = relationship('APIClient', back_populates='service', cascade='all, delete-orphan') use_remailer = Column(Boolean(), default=False, nullable=False) + enable_email_preferences = Column(Boolean(), default=False, nullable=False) class ServiceUser(db.Model): '''Service-related configuration and state for a user @@ -50,9 +51,25 @@ class ServiceUser(db.Model): def has_access(self): return not self.service.limit_access or self.service.access_group in self.user.groups + service_email_id = Column(Integer(), ForeignKey('user_email.id', onupdate='CASCADE', ondelete='SET NULL')) + service_email = relationship('UserEmail') + + @validates('service_email') + def validate_service_email(self, key, value): # pylint: disable=unused-argument + if value is not None: + if not value.user: + value.user = self.user + if value.user != self.user: + raise ValueError('UserEmail assigned to ServiceUser.service_email is not associated with user') + if not value.verified: + raise ValueError('UserEmail assigned to serviceUser.service_email is not verified') + return value + # Actual e-mail address that mails from the service are sent to @property def real_email(self): + if self.service.enable_email_preferences and self.service_email: + return self.service_email.address return self.user.primary_email.address @property @@ -92,10 +109,12 @@ class ServiceUser(db.Model): AliasedUser = db.aliased(User) AliasedPrimaryEmail = db.aliased(UserEmail) + AliasedServiceEmail = db.aliased(UserEmail) AliasedService = db.aliased(Service) query = query.join(cls.user.of_type(AliasedUser)) query = query.join(AliasedUser.primary_email.of_type(AliasedPrimaryEmail)) + query = query.outerjoin(cls.service_email.of_type(AliasedServiceEmail)) query = query.join(cls.service.of_type(AliasedService)) remailer_enabled_expr = AliasedService.use_remailer if remailer.configured else False @@ -104,7 +123,14 @@ class ServiceUser(db.Model): remailer_enabled_expr, AliasedUser.loginname.in_(current_app.config['REMAILER_LIMIT_TO_USERS']), ) - return query.filter(db.and_(db.not_(remailer_enabled_expr), AliasedPrimaryEmail.address == email)) + real_email_matches_expr = db.case( + whens=( + # pylint: disable=singleton-comparison + (db.and_(AliasedService.enable_email_preferences, cls.service_email != None), AliasedServiceEmail.address == email), + ), + else_=(AliasedPrimaryEmail.address == email) + ) + return query.filter(db.and_(db.not_(remailer_enabled_expr), real_email_matches_expr)) @db.event.listens_for(db.Session, 'after_flush') # pylint: disable=no-member def create_service_users(session, flush_context): # pylint: disable=unused-argument diff --git a/uffd/templates/selfservice/self.html b/uffd/templates/selfservice/self.html index 39a36499..6fcb2ed2 100644 --- a/uffd/templates/selfservice/self.html +++ b/uffd/templates/selfservice/self.html @@ -12,7 +12,7 @@ <div class="row"> <div class="col-12 col-md-5"> <h5>{{_("Profile")}}</h5> - <p>{{_("Your profile information is used by all services that are integrated into the Single-Sign-On. Your e-mail address is also used for password recovery.")}}</p> + <p>{{_("Your profile information is used by all services that are integrated into the Single-Sign-On.")}}</p> <p>{{_("Changes may take several minutes to be visible in all services.")}}</p> </div> <div class="col-12 col-md-7"> @@ -77,7 +77,13 @@ <div class="row"> <div class="col-12 col-md-5"> <h5>{{_("E-Mail Preferences")}}</h5> - <p>{{_("Choose which of your verified address to use as your primary or recovery address.")}}</p> + <p> + {{ _("Choose your primary e-mail address and the address password recovery e-mails will be sent to.") }} + {% if user.service_users|map(attribute='service')|selectattr('enable_email_preferences')|list|length %} + {{ _("You can also select different addresses for different services.") }} + {% endif %} + </p> + <p>{{ _("Adresses must be verified before you can select them here.") }}</p> </div> <div class="col-12 col-md-7"> <form class="form" action="{{ url_for("selfservice.update_email_preferences") }}" method="POST"> @@ -90,15 +96,33 @@ </select> </div> <div class="form-group"> - <label>{{_("Recovery Address")}}</label> + <label>{{_("Address for Password Reset E-Mails")}}</label> <select name="recovery_email" class="form-control"> <option value="primary" {{ 'selected' if not user.recovery_email }}>{{ _('Use primary address') }}</option> {% for email in user.all_emails if email.verified %} <option value="{{ email.id }}" {{ 'selected' if email == user.recovery_email }}>{{ email.address }}</option> {% endfor %} </select> - <small class="text-muted">{{ _('Password reset e-mails will be sent to this address') }}</small> </div> + {% set collapse_email_prefs = user.service_users|map(attribute='service')|selectattr('enable_email_preferences')|list|length > 2 %} + {% for service_user in user.service_users if service_user.service.enable_email_preferences %} + {% if collapse_email_prefs and loop.index == 2 %} + <div id="collapsed-email-prefs"> + {% endif %} + <div class="form-group"> + <label>{{ _('Address for Service "%(name)s"', name=service_user.service.name) }}</label> + <select name="service_{{ service_user.service.id }}_email" class="form-control"> + <option value="primary" {{ 'selected' if not service_user.service_email }}>{{ _('Use primary address') }}</option> + {% for email in user.all_emails if email.verified %} + <option value="{{ email.id }}" {{ 'selected' if email == service_user.service_email }}>{{ email.address }}</option> + {% endfor %} + </select> + </div> + {% endfor %} + {% if collapse_email_prefs %} + </div> + <button type="button" class="btn btn-sm btn-link pl-0 mb-1 showmore" data-target="#collapsed-email-prefs" style="display: none;" aria-expanded="false" aria-controls="collapsed-email-prefs">{{ _("Show more settings ...") }}</button> + {% endif %} <button type="submit" class="btn btn-primary btn-block">{{_("Update E-Mail Preferences")}}</button> </form> </div> @@ -194,4 +218,16 @@ </div> </div> +<script> +$(".showmore").each(function () { + $(this).show() + $($(this).data("target")).hide() +}) +$(".showmore").on("click", function () { + $(this).slideUp(200) + $(this).prop("ariaExpanded", true) + $($(this).data("target")).slideDown() +}) +</script> + {% endblock %} diff --git a/uffd/templates/service/show.html b/uffd/templates/service/show.html index c1f075de..55a12328 100644 --- a/uffd/templates/service/show.html +++ b/uffd/templates/service/show.html @@ -39,6 +39,13 @@ {% endif %} </div> </div> + <div class="form-group col"> + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="service-enable-email-preferences" name="enable_email_preferences" value="1" aria-label="enabled" {{ 'checked' if service.enable_email_preferences }}> + <label class="form-check-label" for="service-enable-email-preferences">{{ _('Enable user mail preferences') }}</label> + </div> + </div> + </form> {% if service.id %} diff --git a/uffd/templates/user/show.html b/uffd/templates/user/show.html index 8b814f5d..30a81738 100644 --- a/uffd/templates/user/show.html +++ b/uffd/templates/user/show.html @@ -142,6 +142,17 @@ {% endfor %} </select> </div> + {% for service_user in user.service_users if service_user.service.enable_email_preferences %} + <div class="form-group col"> + <label>{{ _("Address for %(name)s", name=service_user.service.name) }}</label> + <select name="service_{{ service_user.service.id }}_email" class="form-control"> + <option value="primary" {{ 'selected' if not service_user.service_email }}>{{ _('Use primary address') }}</option> + {% for email in user.all_emails if email.verified %} + <option value="{{ email.id }}" {{ 'selected' if email == service_user.service_email }}>{{ email.address }}</option> + {% endfor %} + </select> + </div> + {% endfor %} {% endif %} <div class="form-group col"> @@ -239,7 +250,7 @@ $('#email-rows').on('click', '.delete-new-email-row', function () { let new_email_id = 1; $('#email-rows').on('input', 'tr:last input', function () { $('#email-rows tr:last button.delete-new-email-row').removeClass('d-none'); - $('#email-rows').append({{ new_email_row('TMPID')|tojson }}.replace('TMPID', new_email_id)); + $('#email-rows').append({{ new_email_row('TMPID')|tojson }}.replace(/TMPID/g, new_email_id)); new_email_id ++; }); </script> diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo index d40e990ed5133f9eed8b87e4832fabb4c5e23389..8187048476a9226c2764f6b1fe76560a34e30c9d 100644 GIT binary patch delta 7376 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 S0m@3ygi-=>jp*cgQ)K}<d6cjK delta 6969 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 diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 293df9db..4a00b8e8 100644 --- a/uffd/translations/de/LC_MESSAGES/messages.po +++ b/uffd/translations/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-08-28 17:21+0200\n" +"POT-Creation-Date: 2022-10-19 22:14+0200\n" "PO-Revision-Date: 2021-05-25 21:18+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -140,8 +140,8 @@ msgstr "Über uffd" #: uffd/templates/group/list.html:8 uffd/templates/invite/list.html:6 #: uffd/templates/mail/list.html:8 uffd/templates/role/list.html:8 -#: uffd/templates/service/index.html:8 uffd/templates/service/show.html:50 -#: uffd/templates/service/show.html:78 uffd/templates/user/list.html:8 +#: uffd/templates/service/index.html:8 uffd/templates/service/show.html:57 +#: uffd/templates/service/show.html:85 uffd/templates/user/list.html:8 msgid "New" msgstr "Neu" @@ -156,18 +156,18 @@ msgstr "GID" #: uffd/templates/mfa/setup.html:157 uffd/templates/mfa/setup.html:158 #: uffd/templates/mfa/setup.html:169 uffd/templates/role/list.html:14 #: uffd/templates/rolemod/list.html:9 uffd/templates/rolemod/show.html:44 -#: uffd/templates/selfservice/self.html:165 +#: uffd/templates/selfservice/self.html:189 #: uffd/templates/service/index.html:14 uffd/templates/service/show.html:20 -#: uffd/templates/service/show.html:84 uffd/templates/user/show.html:178 -#: uffd/templates/user/show.html:210 +#: uffd/templates/service/show.html:91 uffd/templates/user/show.html:189 +#: uffd/templates/user/show.html:221 msgid "Name" msgstr "Name" #: uffd/templates/group/list.html:16 uffd/templates/group/show.html:33 #: uffd/templates/invite/new.html:36 uffd/templates/role/list.html:15 #: uffd/templates/role/show.html:48 uffd/templates/rolemod/list.html:10 -#: uffd/templates/rolemod/show.html:26 uffd/templates/selfservice/self.html:166 -#: uffd/templates/user/show.html:179 uffd/templates/user/show.html:211 +#: uffd/templates/rolemod/show.html:26 uffd/templates/selfservice/self.html:190 +#: uffd/templates/user/show.html:190 uffd/templates/user/show.html:222 msgid "Description" msgstr "Beschreibung" @@ -191,9 +191,9 @@ msgstr "Abbrechen" #: uffd/templates/group/show.html:11 uffd/templates/role/show.html:19 #: uffd/templates/role/show.html:21 uffd/templates/selfservice/self.html:61 -#: uffd/templates/selfservice/self.html:180 uffd/templates/service/api.html:11 +#: uffd/templates/selfservice/self.html:204 uffd/templates/service/api.html:11 #: uffd/templates/service/oauth2.html:11 uffd/templates/service/show.html:12 -#: uffd/templates/user/show.html:25 uffd/templates/user/show.html:166 +#: uffd/templates/user/show.html:25 uffd/templates/user/show.html:177 msgid "Are you sure?" msgstr "Wirklich fortfahren?" @@ -234,7 +234,7 @@ msgid "Created by" msgstr "Erstellt durch" #: uffd/templates/invite/list.html:14 uffd/templates/service/api.html:34 -#: uffd/templates/service/show.html:85 +#: uffd/templates/service/show.html:92 msgid "Permissions" msgstr "Berechtigungen" @@ -262,7 +262,7 @@ msgstr "Account-Registrierung" msgid "user signups" msgstr "Account-Registrierungen" -#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:163 +#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:174 msgid "Disabled" msgstr "Deaktiviert" @@ -455,8 +455,8 @@ msgstr "" msgid "One address per line" msgstr "Eine Adresse pro Zeile" -#: uffd/templates/mfa/auth.html:6 uffd/templates/selfservice/self.html:134 -#: uffd/templates/user/show.html:161 +#: uffd/templates/mfa/auth.html:6 uffd/templates/selfservice/self.html:158 +#: uffd/templates/user/show.html:172 msgid "Two-Factor Authentication" msgstr "Zwei-Faktor-Authentifizierung" @@ -563,11 +563,11 @@ msgstr "" msgid "Disable two-factor authentication" msgstr "Zwei-Faktor-Authentifizierung (2FA) deaktivieren" -#: uffd/templates/mfa/setup.html:18 uffd/templates/selfservice/self.html:140 +#: uffd/templates/mfa/setup.html:18 uffd/templates/selfservice/self.html:164 msgid "Two-factor authentication is currently <strong>enabled</strong>." msgstr "Die Zwei-Faktor-Authentifizierung ist derzeit <strong>aktiviert</strong>." -#: uffd/templates/mfa/setup.html:20 uffd/templates/selfservice/self.html:142 +#: uffd/templates/mfa/setup.html:20 uffd/templates/selfservice/self.html:166 msgid "Two-factor authentication is currently <strong>disabled</strong>." msgstr "" "Die Zwei-Faktor-Authentifizierung ist derzeit " @@ -595,7 +595,7 @@ msgid "Reset two-factor configuration" msgstr "Zwei-Faktor-Authentifizierung zurücksetzen" #: uffd/templates/mfa/setup.html:46 uffd/templates/mfa/setup_recovery.html:5 -#: uffd/templates/user/show.html:164 +#: uffd/templates/user/show.html:175 msgid "Recovery Codes" msgstr "Wiederherstellungscodes" @@ -633,7 +633,7 @@ msgstr "Generiere neue Wiederherstellungscodes" msgid "You have no remaining recovery codes." msgstr "Du hast keine Wiederherstellungscodes übrig." -#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:164 +#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:175 msgid "Authenticator Apps (TOTP)" msgstr "Authentifikator-Apps (TOTP)" @@ -663,7 +663,7 @@ msgstr "Registriert am" msgid "No authenticator apps registered yet" msgstr "Bisher keine Authentifikator-Apps registriert" -#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:164 +#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:175 msgid "U2F and FIDO2 Devices" msgstr "U2F und FIDO2 Geräte" @@ -996,12 +996,10 @@ msgstr "Profil" #: uffd/templates/selfservice/self.html:15 msgid "" "Your profile information is used by all services that are integrated into" -" the Single-Sign-On. Your e-mail address is also used for password " -"recovery." +" the Single-Sign-On." msgstr "" "Deine Profilangaben werden von allen Diensten verwendet, die an das " -"Single-Sign-On angeschlossen sind. Die E-Mail-Adresse wird außerdem für " -"die „Passwort vergessen“ genutzt." +"Single-Sign-On angeschlossen sind." #: uffd/templates/selfservice/self.html:16 msgid "Changes may take several minutes to be visible in all services." @@ -1026,8 +1024,8 @@ msgid "" "verify new addresses by opening a link set to them." msgstr "" "Füge neue Adressen zu deinem Account hinzu oder löschen vorhandene. Neue " -"Adressen müssen verifiziert werden, bevor sie verwendet werden können. " -"Dazu wird ein Bestätigungslink an die Adresse geschickt." +"Adressen müssen bestätigt werden, bevor sie verwendet werden können. Dazu" +" erhälst du eine E-Mail mit einem Bestätigungslink." #: uffd/templates/selfservice/self.html:43 msgid "Email" @@ -1047,7 +1045,7 @@ msgstr "primär" #: uffd/templates/selfservice/self.html:57 msgid "unverified" -msgstr "unverifiziert" +msgstr "nicht bestätigt" #: uffd/templates/selfservice/self.html:62 uffd/views/selfservice.py:171 msgid "Cannot delete primary e-mail address" @@ -1055,47 +1053,64 @@ msgstr "Primäre E-Mail-Adresse kann nicht gelöscht werden" #: uffd/templates/selfservice/self.html:65 msgid "Retry verification" -msgstr "Verifikation neustarten" +msgstr "Bestätigungslink neusenden" #: uffd/templates/selfservice/self.html:79 msgid "E-Mail Preferences" msgstr "E-Mail-Einstellungen" -#: uffd/templates/selfservice/self.html:80 +#: uffd/templates/selfservice/self.html:81 msgid "" -"Choose which of your verified address to use as your primary or recovery " -"address." +"Choose your primary e-mail address and the address password recovery " +"e-mails will be sent to." msgstr "" -"Wähle aus deinen verifizierten Adressen die primäre Adresse und die " -"Wiederherstellungsadresse." +"Wähle deine primäre Adresse und die Adresse für Passwort-" +"Zurücksetzen-E-Mails aus." -#: uffd/templates/selfservice/self.html:85 +#: uffd/templates/selfservice/self.html:83 +msgid "You can also select different addresses for different services." +msgstr "" +"Du kannst für unterschiedliche Dienste unterschiedliche Adressen " +"verwenden." + +#: uffd/templates/selfservice/self.html:86 +msgid "Adresses must be verified before you can select them here." +msgstr "Adressen müssen bestätigt sein, damit du sie hier auswählen kannst." + +#: uffd/templates/selfservice/self.html:91 msgid "Primary Address" msgstr "Primäre Adresse" -#: uffd/templates/selfservice/self.html:93 -msgid "Recovery Address" -msgstr "Wiederherstellungsadresse" +#: uffd/templates/selfservice/self.html:99 +msgid "Address for Password Reset E-Mails" +msgstr "Adresse für Passwort-Zurücksetzen-E-Mails" -#: uffd/templates/selfservice/self.html:95 uffd/templates/user/show.html:139 +#: uffd/templates/selfservice/self.html:101 +#: uffd/templates/selfservice/self.html:115 uffd/templates/user/show.html:139 +#: uffd/templates/user/show.html:149 msgid "Use primary address" -msgstr "Verwende primäre Adresse" +msgstr "Primäre Adresse verwenden" + +#: uffd/templates/selfservice/self.html:113 +#, python-format +msgid "Address for Service \"%(name)s\"" +msgstr "Adresse für Dienst „%(name)s“" -#: uffd/templates/selfservice/self.html:100 -msgid "Password reset e-mails will be sent to this address" -msgstr "E-Mails zur zurücksetzen des Passworts werden an diese Adresse gesendet" +#: uffd/templates/selfservice/self.html:124 +msgid "Show more settings ..." +msgstr "Weitere Einstellungen anzeigen ..." -#: uffd/templates/selfservice/self.html:102 +#: uffd/templates/selfservice/self.html:126 msgid "Update E-Mail Preferences" msgstr "E-Mail-Einstellungen speichern" -#: uffd/templates/selfservice/self.html:111 +#: uffd/templates/selfservice/self.html:135 #: uffd/templates/session/login.html:16 uffd/templates/signup/start.html:36 -#: uffd/templates/user/show.html:148 +#: uffd/templates/user/show.html:159 msgid "Password" msgstr "Passwort" -#: uffd/templates/selfservice/self.html:112 +#: uffd/templates/selfservice/self.html:136 msgid "" "Your login password for the Single-Sign-On. Only enter it on the Single-" "Sign-On login page! No other legit websites will ask you for this " @@ -1106,22 +1121,22 @@ msgstr "" " Webseite wird dich nach diesem Passwort fragen. Es wird auch niemals für" " Support-Anfragen benötigt." -#: uffd/templates/selfservice/self.html:117 +#: uffd/templates/selfservice/self.html:141 #: uffd/templates/selfservice/set_password.html:9 msgid "New Password" msgstr "Neues Passwort" -#: uffd/templates/selfservice/self.html:123 +#: uffd/templates/selfservice/self.html:147 #: uffd/templates/selfservice/set_password.html:16 #: uffd/templates/signup/start.html:43 msgid "Repeat Password" msgstr "Passwort wiederholen" -#: uffd/templates/selfservice/self.html:125 +#: uffd/templates/selfservice/self.html:149 msgid "Change Password" msgstr "Passwort ändern" -#: uffd/templates/selfservice/self.html:135 +#: uffd/templates/selfservice/self.html:159 msgid "" "Setting up Two-Factor Authentication (2FA) adds an additional step to the" " Single-Sign-On login and increases the security of your account " @@ -1131,17 +1146,17 @@ msgstr "" "Anmeldung im Single-Sign-On hinzu und verbessert damit die Sicherheit " "deines Accounts erheblich." -#: uffd/templates/selfservice/self.html:145 +#: uffd/templates/selfservice/self.html:169 msgid "Manage two-factor authentication" msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten" -#: uffd/templates/selfservice/self.html:153 uffd/templates/user/list.html:20 -#: uffd/templates/user/show.html:35 uffd/templates/user/show.html:173 +#: uffd/templates/selfservice/self.html:177 uffd/templates/user/list.html:20 +#: uffd/templates/user/show.html:35 uffd/templates/user/show.html:184 #: uffd/views/role.py:21 msgid "Roles" msgstr "Rollen" -#: uffd/templates/selfservice/self.html:154 +#: uffd/templates/selfservice/self.html:178 msgid "" "Aside from a set of base permissions, your roles determine the " "permissions of your account." @@ -1149,7 +1164,7 @@ msgstr "" "Deine Berechtigungen werden, von einigen Basis-Berechtigungen abgesehen, " "von deinen Rollen bestimmt" -#: uffd/templates/selfservice/self.html:156 +#: uffd/templates/selfservice/self.html:180 #, python-format msgid "" "See <a href=\"%(services_url)s\">Services</a> for an overview of your " @@ -1158,13 +1173,13 @@ msgstr "" "Auf <a href=\"%(services_url)s\">Dienste</a> erhälst du einen Überblick " "über deine aktuellen Berechtigungen." -#: uffd/templates/selfservice/self.html:160 +#: uffd/templates/selfservice/self.html:184 msgid "Administrators and role moderators can invite you to new roles." msgstr "" "Accounts mit Adminrechten oder Rollen-Moderationsrechten können dich zu " "Rollen einladen." -#: uffd/templates/selfservice/self.html:175 +#: uffd/templates/selfservice/self.html:199 msgid "" "Some permissions in this role require you to setup two-factor " "authentication" @@ -1172,11 +1187,11 @@ msgstr "" "Einige Berechtigungen dieser Rolle erfordern das Einrichten von Zwei-" "Faktor-Authentifikation" -#: uffd/templates/selfservice/self.html:181 +#: uffd/templates/selfservice/self.html:205 msgid "Leave" msgstr "Verlassen" -#: uffd/templates/selfservice/self.html:188 +#: uffd/templates/selfservice/self.html:212 msgid "You currently don't have any roles" msgstr "Du hast derzeit keine Rollen" @@ -1220,7 +1235,7 @@ msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert" msgid "Access uffd metrics" msgstr "Zugriff auf uffd-Metriken" -#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:56 +#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:63 msgid "Client ID" msgstr "Client-ID" @@ -1293,6 +1308,10 @@ msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff" msgid "Hide mail addresses with remailer" msgstr "Verstecke Mailadressen mit dem Remailer" +#: uffd/templates/service/show.html:45 +msgid "Enable user mail preferences" +msgstr "User E-Mail-Einstellungen aktivieren" + #: uffd/templates/session/deviceauth.html:15 msgid "Log into a service on another device without entering your password." msgstr "" @@ -1550,23 +1569,28 @@ msgstr "Primäre E-Mail-Adresse" msgid "Recovery E-Mail Address" msgstr "Wiederherstellungs-E-Mail-Adresse" -#: uffd/templates/user/show.html:152 +#: uffd/templates/user/show.html:147 +#, python-format +msgid "Address for %(name)s" +msgstr "Adresse für %(name)s" + +#: uffd/templates/user/show.html:163 msgid "E-Mail to set it will be sent" msgstr "Mail zum Setzen wird versendet" -#: uffd/templates/user/show.html:163 +#: uffd/templates/user/show.html:174 msgid "Status:" msgstr "Status:" -#: uffd/templates/user/show.html:163 +#: uffd/templates/user/show.html:174 msgid "Enabled" msgstr "Aktiv" -#: uffd/templates/user/show.html:166 +#: uffd/templates/user/show.html:177 msgid "Reset 2FA" msgstr "2FA zurücksetzen" -#: uffd/templates/user/show.html:206 +#: uffd/templates/user/show.html:217 msgid "Resulting groups (only updated after save)" msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)" @@ -1810,7 +1834,7 @@ msgid "E-Mail address already exists" msgstr "E-Mail-Adresse existiert bereits" #: uffd/views/selfservice.py:124 uffd/views/selfservice.py:158 -#: uffd/views/selfservice.py:221 +#: uffd/views/selfservice.py:223 #, python-format msgid "E-Mail to \"%(mail_address)s\" could not be sent!" msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!" @@ -1835,11 +1859,11 @@ msgstr "E-Mail-Adresse verifiziert" msgid "E-Mail address deleted" msgstr "E-Mail-Adresse gelöscht" -#: uffd/views/selfservice.py:192 +#: uffd/views/selfservice.py:194 msgid "E-Mail preferences updated" msgstr "E-Mail-Einstellungen geändert" -#: uffd/views/selfservice.py:203 +#: uffd/views/selfservice.py:205 #, python-format msgid "You left role %(role_name)s" msgstr "Rolle %(role_name)s verlassen" @@ -1906,29 +1930,29 @@ msgstr "Accounts" msgid "Login name does not meet requirements" msgstr "Anmeldename entspricht nicht den Anforderungen" -#: uffd/views/user.py:96 +#: uffd/views/user.py:98 msgid "Display name does not meet requirements" msgstr "Anzeigename entspricht nicht den Anforderungen" -#: uffd/views/user.py:102 +#: uffd/views/user.py:104 msgid "Password is invalid" msgstr "Passwort ist ungültig" -#: uffd/views/user.py:118 +#: uffd/views/user.py:120 msgid "Service user created" msgstr "Service-Account erstellt" -#: uffd/views/user.py:121 +#: uffd/views/user.py:123 msgid "User created. We sent the user a password reset link by e-mail" msgstr "" "Account erstellt. E-Mail mit einem Link zum Setzen des Passworts wurde " "versendet." -#: uffd/views/user.py:123 +#: uffd/views/user.py:125 msgid "User updated" msgstr "Account aktualisiert" -#: uffd/views/user.py:133 +#: uffd/views/user.py:135 msgid "Deleted user" msgstr "Account gelöscht" diff --git a/uffd/views/selfservice.py b/uffd/views/selfservice.py index 95883536..5d9b337b 100644 --- a/uffd/views/selfservice.py +++ b/uffd/views/selfservice.py @@ -177,17 +177,19 @@ def delete_email(email_id): @login_required(selfservice_acl_check) def update_email_preferences(): verified_emails = UserEmail.query.filter_by(user=request.user, verified=True) - email = verified_emails.filter_by(id=request.form['primary_email']).first() - if not email: - abort(400) - request.user.primary_email = email + request.user.primary_email = verified_emails.filter_by(id=request.form['primary_email']).one() if request.form['recovery_email'] == 'primary': request.user.recovery_email = None else: - email = verified_emails.filter_by(id=request.form['recovery_email']).first() - if not email: - abort(400) - request.user.recovery_email = email + request.user.recovery_email = verified_emails.filter_by(id=request.form['recovery_email']).one() + for service_user in request.user.service_users: + if not service_user.service.enable_email_preferences: + continue + value = request.form.get(f'service_{service_user.service.id}_email', 'primary') + if value == 'primary': + service_user.service_email = None + else: + service_user.service_email = verified_emails.filter_by(id=value).one() db.session.commit() flash(_('E-Mail preferences updated')) return redirect(url_for('selfservice.index')) diff --git a/uffd/views/service.py b/uffd/views/service.py index cc4ee8d7..a8c9e28d 100644 --- a/uffd/views/service.py +++ b/uffd/views/service.py @@ -72,6 +72,7 @@ def edit_submit(id=None): service.limit_access = True service.access_group = Group.query.get(request.form['access-group']) service.use_remailer = request.form.get('use_remailer') == '1' + service.enable_email_preferences = request.form.get('enable_email_preferences') == '1' db.session.commit() return redirect(url_for('service.show', id=service.id)) diff --git a/uffd/views/user.py b/uffd/views/user.py index 3067a4cb..b3125667 100644 --- a/uffd/views/user.py +++ b/uffd/views/user.py @@ -1,7 +1,7 @@ import csv import io -from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, abort +from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app from flask_babel import gettext as _, lazy_gettext from sqlalchemy.exc import IntegrityError @@ -75,17 +75,19 @@ def update(id=None): db.session.add(email) verified_emails = UserEmail.query.filter_by(user=user, verified=True) - email = verified_emails.filter_by(id=request.form['primary_email']).first() - if not email: - abort(400) - user.primary_email = email + user.primary_email = verified_emails.filter_by(id=request.form['primary_email']).one() if request.form['recovery_email'] == 'primary': user.recovery_email = None else: - email = verified_emails.filter_by(id=request.form['recovery_email']).first() - if not email: - abort(400) - user.recovery_email = email + user.recovery_email = verified_emails.filter_by(id=request.form['recovery_email']).one() + for service_user in user.service_users: + if not service_user.service.enable_email_preferences: + continue + value = request.form.get(f'service_{service_user.service.id}_email', 'primary') + if value == 'primary': + service_user.service_email = None + else: + service_user.service_email = verified_emails.filter_by(id=value).one() for email in user.all_emails: if request.form.get(f'email-{email.id}-delete') == '1': -- GitLab